1058 lines
		
	
	
		
			40 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			1058 lines
		
	
	
		
			40 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using Microsoft.AspNetCore.Mvc;
 | |
| using Microsoft.EntityFrameworkCore;
 | |
| using System.ComponentModel.DataAnnotations;
 | |
| using DysonNetwork.Shared;
 | |
| using DysonNetwork.Shared.Auth;
 | |
| using DysonNetwork.Shared.Proto;
 | |
| using DysonNetwork.Shared.Registry;
 | |
| using DysonNetwork.Sphere.Localization;
 | |
| 
 | |
| using Grpc.Core;
 | |
| using Microsoft.AspNetCore.Authorization;
 | |
| using Microsoft.Extensions.Localization;
 | |
| using NodaTime;
 | |
| using DysonNetwork.Shared.Models;
 | |
| 
 | |
| namespace DysonNetwork.Sphere.Chat;
 | |
| 
 | |
| [ApiController]
 | |
| [Route("/api/chat")]
 | |
| public class ChatRoomController(
 | |
|     AppDatabase db,
 | |
|     ChatRoomService crs,
 | |
|     RemoteRealmService rs,
 | |
|     IStringLocalizer<NotificationResource> localizer,
 | |
|     AccountService.AccountServiceClient accounts,
 | |
|     FileService.FileServiceClient files,
 | |
|     FileReferenceService.FileReferenceServiceClient fileRefs,
 | |
|     ActionLogService.ActionLogServiceClient als,
 | |
|     RingService.RingServiceClient pusher,
 | |
|     RemoteAccountService remoteAccountsHelper
 | |
| ) : ControllerBase
 | |
| {
 | |
|     [HttpGet("{id:guid}")]
 | |
|     public async Task<ActionResult<SnChatRoom>> GetChatRoom(Guid id)
 | |
|     {
 | |
|         var chatRoom = await db.ChatRooms
 | |
|             .Where(c => c.Id == id)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (chatRoom is null) return NotFound();
 | |
| 
 | |
|         if (chatRoom.RealmId != null)
 | |
|             chatRoom.Realm = await rs.GetRealm(chatRoom.RealmId.Value.ToString());
 | |
| 
 | |
|         if (chatRoom.Type != ChatRoomType.DirectMessage) return Ok(chatRoom);
 | |
| 
 | |
|         if (HttpContext.Items["CurrentUser"] is Account currentUser)
 | |
|             chatRoom = await crs.LoadDirectMessageMembers(chatRoom, Guid.Parse(currentUser.Id));
 | |
| 
 | |
|         return Ok(chatRoom);
 | |
|     }
 | |
| 
 | |
|     [HttpGet]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult<List<SnChatRoom>>> ListJoinedChatRooms()
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | |
|             return Unauthorized();
 | |
|         var accountId = Guid.Parse(currentUser.Id);
 | |
| 
 | |
|         var chatRooms = await db.ChatMembers
 | |
|             .Where(m => m.AccountId == accountId)
 | |
|             .Where(m => m.JoinedAt != null && m.LeaveAt == null)
 | |
|             .Include(m => m.ChatRoom)
 | |
|             .Select(m => m.ChatRoom)
 | |
|             .ToListAsync();
 | |
|         chatRooms = await crs.LoadDirectMessageMembers(chatRooms, accountId);
 | |
|         chatRooms = await crs.SortChatRoomByLastMessage(chatRooms);
 | |
| 
 | |
|         return Ok(chatRooms);
 | |
|     }
 | |
| 
 | |
|     public class DirectMessageRequest
 | |
|     {
 | |
|         [Required] public Guid RelatedUserId { get; set; }
 | |
|     }
 | |
| 
 | |
|     [HttpPost("direct")]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult<SnChatRoom>> CreateDirectMessage([FromBody] DirectMessageRequest request)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | |
|             return Unauthorized();
 | |
| 
 | |
|         var relatedUser = await accounts.GetAccountAsync(
 | |
|             new GetAccountRequest { Id = request.RelatedUserId.ToString() }
 | |
|         );
 | |
|         if (relatedUser is null)
 | |
|             return BadRequest("Related user was not found");
 | |
| 
 | |
|         var hasBlocked = await accounts.HasRelationshipAsync(new GetRelationshipRequest()
 | |
|         {
 | |
|             AccountId = currentUser.Id,
 | |
|             RelatedId = request.RelatedUserId.ToString(),
 | |
|             Status = -100
 | |
|         });
 | |
|         if (hasBlocked?.Value ?? false)
 | |
|             return StatusCode(403, "You cannot create direct message with a user that blocked you.");
 | |
| 
 | |
|         // Check if DM already exists between these users
 | |
|         var existingDm = await db.ChatRooms
 | |
|             .Include(c => c.Members)
 | |
|             .Where(c => c.Type == ChatRoomType.DirectMessage && c.Members.Count == 2)
 | |
|             .Where(c => c.Members.Any(m => m.AccountId == Guid.Parse(currentUser.Id)))
 | |
|             .Where(c => c.Members.Any(m => m.AccountId == request.RelatedUserId))
 | |
|             .FirstOrDefaultAsync();
 | |
| 
 | |
|         if (existingDm != null)
 | |
|             return BadRequest("You already have a DM with this user.");
 | |
| 
 | |
|         // Create new DM chat room
 | |
|         var dmRoom = new SnChatRoom
 | |
|         {
 | |
|             Type = ChatRoomType.DirectMessage,
 | |
|             IsPublic = false,
 | |
|             Members = new List<SnChatMember>
 | |
|             {
 | |
|                 new()
 | |
|                 {
 | |
|                     AccountId = Guid.Parse(currentUser.Id),
 | |
|                     Role = ChatMemberRole.Owner,
 | |
|                     JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow)
 | |
|                 },
 | |
|                 new()
 | |
|                 {
 | |
|                     AccountId = request.RelatedUserId,
 | |
|                     Role = ChatMemberRole.Member,
 | |
|                     JoinedAt = null, // Pending status
 | |
|                 }
 | |
|             }
 | |
|         };
 | |
| 
 | |
|         db.ChatRooms.Add(dmRoom);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         _ = als.CreateActionLogAsync(new CreateActionLogRequest
 | |
|         {
 | |
|             Action = "chatrooms.create",
 | |
|             Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(dmRoom.Id.ToString()) } },
 | |
|             AccountId = currentUser.Id,
 | |
|             UserAgent = Request.Headers.UserAgent,
 | |
|             IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
 | |
|         });
 | |
| 
 | |
|         var invitedMember = dmRoom.Members.First(m => m.AccountId == request.RelatedUserId);
 | |
|         invitedMember.ChatRoom = dmRoom;
 | |
|         await _SendInviteNotify(invitedMember, currentUser);
 | |
| 
 | |
|         return Ok(dmRoom);
 | |
|     }
 | |
| 
 | |
|     [HttpGet("direct/{accountId:guid}")]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult<SnChatRoom>> GetDirectChatRoom(Guid accountId)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser)
 | |
|             return Unauthorized();
 | |
| 
 | |
|         var room = await db.ChatRooms
 | |
|             .Include(c => c.Members)
 | |
|             .Where(c => c.Type == ChatRoomType.DirectMessage && c.Members.Count == 2)
 | |
|             .Where(c => c.Members.Any(m => m.AccountId == Guid.Parse(currentUser.Id)))
 | |
|             .Where(c => c.Members.Any(m => m.AccountId == accountId))
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (room is null) return NotFound();
 | |
| 
 | |
|         return Ok(room);
 | |
|     }
 | |
| 
 | |
|     public class ChatRoomRequest
 | |
|     {
 | |
|         [Required][MaxLength(1024)] public string? Name { get; set; }
 | |
|         [MaxLength(4096)] public string? Description { get; set; }
 | |
|         [MaxLength(32)] public string? PictureId { get; set; }
 | |
|         [MaxLength(32)] public string? BackgroundId { get; set; }
 | |
|         public Guid? RealmId { get; set; }
 | |
|         public bool? IsCommunity { get; set; }
 | |
|         public bool? IsPublic { get; set; }
 | |
|     }
 | |
| 
 | |
|     [HttpPost]
 | |
|     [Authorize]
 | |
|     [RequiredPermission("global", "chat.create")]
 | |
|     public async Task<ActionResult<SnChatRoom>> CreateChatRoom(ChatRoomRequest request)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) return Unauthorized();
 | |
|         if (request.Name is null) return BadRequest("You cannot create a chat room without a name.");
 | |
| 
 | |
|         var chatRoom = new SnChatRoom
 | |
|         {
 | |
|             Name = request.Name,
 | |
|             Description = request.Description ?? string.Empty,
 | |
|             IsCommunity = request.IsCommunity ?? false,
 | |
|             IsPublic = request.IsPublic ?? false,
 | |
|             Type = ChatRoomType.Group,
 | |
|             Members = new List<SnChatMember>
 | |
|             {
 | |
|                 new()
 | |
|                 {
 | |
|                     Role = ChatMemberRole.Owner,
 | |
|                     AccountId = Guid.Parse(currentUser.Id),
 | |
|                     JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow)
 | |
|                 }
 | |
|             }
 | |
|         };
 | |
| 
 | |
|         if (request.RealmId is not null)
 | |
|         {
 | |
|             if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id),
 | |
|                     [RealmMemberRole.Moderator]))
 | |
|                 return StatusCode(403, "You need at least be a moderator to create chat linked to the realm.");
 | |
|             chatRoom.RealmId = request.RealmId;
 | |
|         }
 | |
| 
 | |
|         if (request.PictureId is not null)
 | |
|         {
 | |
|             try
 | |
|             {
 | |
|                 var fileResponse = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
 | |
|                 if (fileResponse == null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
 | |
|                 chatRoom.Picture = SnCloudFileReferenceObject.FromProtoValue(fileResponse);
 | |
| 
 | |
|                 await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
 | |
|                 {
 | |
|                     FileId = fileResponse.Id,
 | |
|                     Usage = "chatroom.picture",
 | |
|                     ResourceId = chatRoom.ResourceIdentifier,
 | |
|                 });
 | |
|             }
 | |
|             catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
 | |
|             {
 | |
|                 return BadRequest("Invalid picture id, unable to find the file on cloud.");
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         if (request.BackgroundId is not null)
 | |
|         {
 | |
|             try
 | |
|             {
 | |
|                 var fileResponse = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
 | |
|                 if (fileResponse == null) return BadRequest("Invalid background id, unable to find the file on cloud.");
 | |
|                 chatRoom.Background = SnCloudFileReferenceObject.FromProtoValue(fileResponse);
 | |
| 
 | |
|                 await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
 | |
|                 {
 | |
|                     FileId = fileResponse.Id,
 | |
|                     Usage = "chatroom.background",
 | |
|                     ResourceId = chatRoom.ResourceIdentifier,
 | |
|                 });
 | |
|             }
 | |
|             catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
 | |
|             {
 | |
|                 return BadRequest("Invalid background id, unable to find the file on cloud.");
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         db.ChatRooms.Add(chatRoom);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         var chatRoomResourceId = $"chatroom:{chatRoom.Id}";
 | |
| 
 | |
|         if (chatRoom.Picture is not null)
 | |
|         {
 | |
|             await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
 | |
|             {
 | |
|                 FileId = chatRoom.Picture.Id,
 | |
|                 Usage = "chat.room.picture",
 | |
|                 ResourceId = chatRoomResourceId
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         if (chatRoom.Background is not null)
 | |
|         {
 | |
|             await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
 | |
|             {
 | |
|                 FileId = chatRoom.Background.Id,
 | |
|                 Usage = "chat.room.background",
 | |
|                 ResourceId = chatRoomResourceId
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         _ = als.CreateActionLogAsync(new CreateActionLogRequest
 | |
|         {
 | |
|             Action = "chatrooms.create",
 | |
|             Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) } },
 | |
|             AccountId = currentUser.Id,
 | |
|             UserAgent = Request.Headers.UserAgent,
 | |
|             IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
 | |
|         });
 | |
| 
 | |
|         return Ok(chatRoom);
 | |
|     }
 | |
| 
 | |
| 
 | |
|     [HttpPatch("{id:guid}")]
 | |
|     public async Task<ActionResult<SnChatRoom>> UpdateChatRoom(Guid id, [FromBody] ChatRoomRequest request)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) return Unauthorized();
 | |
| 
 | |
|         var chatRoom = await db.ChatRooms
 | |
|             .Where(e => e.Id == id)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (chatRoom is null) return NotFound();
 | |
| 
 | |
|         if (chatRoom.RealmId is not null)
 | |
|         {
 | |
|             if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
 | |
|                     [RealmMemberRole.Moderator]))
 | |
|                 return StatusCode(403, "You need at least be a realm moderator to update the chat.");
 | |
|         }
 | |
|         else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Moderator))
 | |
|             return StatusCode(403, "You need at least be a moderator to update the chat.");
 | |
| 
 | |
|         if (request.RealmId is not null)
 | |
|         {
 | |
|             if (!await rs.IsMemberWithRole(request.RealmId.Value, Guid.Parse(currentUser.Id), [RealmMemberRole.Moderator]))
 | |
|                 return StatusCode(403, "You need at least be a moderator to transfer the chat linked to the realm.");
 | |
|             chatRoom.RealmId = request.RealmId;
 | |
|         }
 | |
| 
 | |
|         if (request.PictureId is not null)
 | |
|         {
 | |
|             try
 | |
|             {
 | |
|                 var fileResponse = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
 | |
|                 if (fileResponse == null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
 | |
| 
 | |
|                 // Remove old references for pictures
 | |
|                 await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
 | |
|                 {
 | |
|                     ResourceId = chatRoom.ResourceIdentifier,
 | |
|                     Usage = "chat.room.picture"
 | |
|                 });
 | |
| 
 | |
|                 // Add a new reference
 | |
|                 await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
 | |
|                 {
 | |
|                     FileId = fileResponse.Id,
 | |
|                     Usage = "chat.room.picture",
 | |
|                     ResourceId = chatRoom.ResourceIdentifier
 | |
|                 });
 | |
| 
 | |
|                 chatRoom.Picture = SnCloudFileReferenceObject.FromProtoValue(fileResponse);
 | |
|             }
 | |
|             catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
 | |
|             {
 | |
|                 return BadRequest("Invalid picture id, unable to find the file on cloud.");
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         if (request.BackgroundId is not null)
 | |
|         {
 | |
|             try
 | |
|             {
 | |
|                 var fileResponse = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
 | |
|                 if (fileResponse == null) return BadRequest("Invalid background id, unable to find the file on cloud.");
 | |
| 
 | |
|                 // Remove old references for backgrounds
 | |
|                 await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
 | |
|                 {
 | |
|                     ResourceId = chatRoom.ResourceIdentifier,
 | |
|                     Usage = "chat.room.background"
 | |
|                 });
 | |
| 
 | |
|                 // Add a new reference
 | |
|                 await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
 | |
|                 {
 | |
|                     FileId = fileResponse.Id,
 | |
|                     Usage = "chat.room.background",
 | |
|                     ResourceId = chatRoom.ResourceIdentifier
 | |
|                 });
 | |
| 
 | |
|                 chatRoom.Background = SnCloudFileReferenceObject.FromProtoValue(fileResponse);
 | |
|             }
 | |
|             catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
 | |
|             {
 | |
|                 return BadRequest("Invalid background id, unable to find the file on cloud.");
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         if (request.Name is not null)
 | |
|             chatRoom.Name = request.Name;
 | |
|         if (request.Description is not null)
 | |
|             chatRoom.Description = request.Description;
 | |
|         if (request.IsCommunity is not null)
 | |
|             chatRoom.IsCommunity = request.IsCommunity.Value;
 | |
|         if (request.IsPublic is not null)
 | |
|             chatRoom.IsPublic = request.IsPublic.Value;
 | |
| 
 | |
|         db.ChatRooms.Update(chatRoom);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         _ = als.CreateActionLogAsync(new CreateActionLogRequest
 | |
|         {
 | |
|             Action = "chatrooms.update",
 | |
|             Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) } },
 | |
|             AccountId = currentUser.Id,
 | |
|             UserAgent = Request.Headers.UserAgent,
 | |
|             IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
 | |
|         });
 | |
| 
 | |
|         return Ok(chatRoom);
 | |
|     }
 | |
| 
 | |
|     [HttpDelete("{id:guid}")]
 | |
|     public async Task<ActionResult> DeleteChatRoom(Guid id)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) return Unauthorized();
 | |
| 
 | |
|         var chatRoom = await db.ChatRooms
 | |
|             .Where(e => e.Id == id)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (chatRoom is null) return NotFound();
 | |
| 
 | |
|         if (chatRoom.RealmId is not null)
 | |
|         {
 | |
|             if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
 | |
|                     [RealmMemberRole.Moderator]))
 | |
|                 return StatusCode(403, "You need at least be a realm moderator to delete the chat.");
 | |
|         }
 | |
|         else if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), ChatMemberRole.Owner))
 | |
|             return StatusCode(403, "You need at least be the owner to delete the chat.");
 | |
| 
 | |
|         var chatRoomResourceId = $"chatroom:{chatRoom.Id}";
 | |
| 
 | |
|         // Delete all file references for this chat room
 | |
|         await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
 | |
|         {
 | |
|             ResourceId = chatRoomResourceId
 | |
|         });
 | |
| 
 | |
|         await using var transaction = await db.Database.BeginTransactionAsync();
 | |
| 
 | |
|         try
 | |
|         {
 | |
|             var now = SystemClock.Instance.GetCurrentInstant();
 | |
|             await db.ChatMessages
 | |
|                 .Where(m => m.ChatRoomId == id)
 | |
|                 .ExecuteUpdateAsync(s => s.SetProperty(s => s.DeletedAt, now));
 | |
|             await db.ChatMembers
 | |
|                 .Where(m => m.ChatRoomId == id)
 | |
|                 .ExecuteUpdateAsync(s => s.SetProperty(s => s.DeletedAt, now));
 | |
|             await db.SaveChangesAsync();
 | |
| 
 | |
|             db.ChatRooms.Remove(chatRoom);
 | |
|             await db.SaveChangesAsync();
 | |
| 
 | |
|             await transaction.CommitAsync();
 | |
|         }
 | |
|         catch (Exception)
 | |
|         {
 | |
|             await transaction.RollbackAsync();
 | |
|             throw;
 | |
|         }
 | |
| 
 | |
|         _ = als.CreateActionLogAsync(new CreateActionLogRequest
 | |
|         {
 | |
|             Action = "chatrooms.delete",
 | |
|             Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) } },
 | |
|             AccountId = currentUser.Id,
 | |
|             UserAgent = Request.Headers.UserAgent,
 | |
|             IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
 | |
|         });
 | |
| 
 | |
|         return NoContent();
 | |
|     }
 | |
| 
 | |
|     [HttpGet("{roomId:guid}/members/me")]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult<SnChatMember>> GetRoomIdentity(Guid roomId)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
 | |
|             return Unauthorized();
 | |
| 
 | |
|         var member = await db.ChatMembers
 | |
|             .Where(m => m.AccountId == Guid.Parse(currentUser.Id) && m.ChatRoomId == roomId)
 | |
|             .Where(m => m.JoinedAt != null && m.LeaveAt == null)
 | |
|             .FirstOrDefaultAsync();
 | |
| 
 | |
|         if (member == null)
 | |
|             return NotFound();
 | |
| 
 | |
|         return Ok(await crs.LoadMemberAccount(member));
 | |
|     }
 | |
| 
 | |
|     [HttpGet("{roomId:guid}/members/online")]
 | |
|     public async Task<ActionResult<int>> GetOnlineUsersCount(Guid roomId)
 | |
|     {
 | |
|         var currentUser = HttpContext.Items["CurrentUser"] as Account;
 | |
| 
 | |
|         var room = await db.ChatRooms
 | |
|             .FirstOrDefaultAsync(r => r.Id == roomId);
 | |
|         if (room is null) return NotFound();
 | |
| 
 | |
|         if (!room.IsPublic)
 | |
|         {
 | |
|             if (currentUser is null) return Unauthorized();
 | |
|             var member = await db.ChatMembers
 | |
|                 .Where(m => m.ChatRoomId == roomId && m.AccountId == Guid.Parse(currentUser.Id) && m.JoinedAt != null && m.LeaveAt == null)
 | |
|                 .FirstOrDefaultAsync();
 | |
|             if (member is null) return StatusCode(403, "You need to be a member to see online count of private chat room.");
 | |
|         }
 | |
| 
 | |
|         var members = await db.ChatMembers
 | |
|             .Where(m => m.ChatRoomId == roomId)
 | |
|             .Where(m => m.JoinedAt != null && m.LeaveAt == null)
 | |
|             .Select(m => m.AccountId)
 | |
|             .ToListAsync();
 | |
| 
 | |
|         var memberStatuses = await remoteAccountsHelper.GetAccountStatusBatch(members);
 | |
| 
 | |
|         var onlineCount = memberStatuses.Count(s => s.Value.IsOnline);
 | |
| 
 | |
|         return Ok(onlineCount);
 | |
|     }
 | |
| 
 | |
|     [HttpGet("{roomId:guid}/members")]
 | |
|     public async Task<ActionResult<List<SnChatMember>>> ListMembers(Guid roomId,
 | |
|         [FromQuery] int take = 20,
 | |
|         [FromQuery] int offset = 0,
 | |
|         [FromQuery] bool withStatus = false
 | |
|     )
 | |
|     {
 | |
|         var currentUser = HttpContext.Items["CurrentUser"] as Account;
 | |
| 
 | |
|         var room = await db.ChatRooms
 | |
|             .FirstOrDefaultAsync(r => r.Id == roomId);
 | |
|         if (room is null) return NotFound();
 | |
| 
 | |
|         if (!room.IsPublic)
 | |
|         {
 | |
|             if (currentUser is null) return Unauthorized();
 | |
|             var member = await db.ChatMembers
 | |
|                 .Where(m => m.ChatRoomId == roomId && m.AccountId == Guid.Parse(currentUser.Id) && m.JoinedAt != null && m.LeaveAt == null)
 | |
|                 .FirstOrDefaultAsync();
 | |
|             if (member is null) return StatusCode(403, "You need to be a member to see members of private chat room.");
 | |
|         }
 | |
| 
 | |
|         var query = db.ChatMembers
 | |
|             .Where(m => m.ChatRoomId == roomId)
 | |
|             .Where(m => m.JoinedAt != null && m.LeaveAt == null);
 | |
| 
 | |
|         if (withStatus)
 | |
|         {
 | |
|             var members = await query
 | |
|                 .OrderBy(m => m.JoinedAt)
 | |
|                 .ToListAsync();
 | |
| 
 | |
|             var memberStatuses = await remoteAccountsHelper.GetAccountStatusBatch(
 | |
|                 members.Select(m => m.AccountId).ToList()
 | |
|             );
 | |
| 
 | |
|             members = members
 | |
|                 .Select(m =>
 | |
|                 {
 | |
|                     m.Status = memberStatuses.TryGetValue(m.AccountId, out var s) ? s : null;
 | |
|                     return m;
 | |
|                 })
 | |
|                 .OrderByDescending(m => m.Status?.IsOnline ?? false)
 | |
|                 .ToList();
 | |
| 
 | |
|             var total = members.Count;
 | |
|             Response.Headers.Append("X-Total", total.ToString());
 | |
| 
 | |
|             var result = members.Skip(offset).Take(take).ToList();
 | |
| 
 | |
|             members = await crs.LoadMemberAccounts(result);
 | |
| 
 | |
|             return Ok(members.Where(m => m.Account is not null).ToList());
 | |
|         }
 | |
|         else
 | |
|         {
 | |
|             var total = await query.CountAsync();
 | |
|             Response.Headers.Append("X-Total", total.ToString());
 | |
| 
 | |
|             var members = await query
 | |
|                 .OrderBy(m => m.JoinedAt)
 | |
|                 .Skip(offset)
 | |
|                 .Take(take)
 | |
|                 .ToListAsync();
 | |
|             members = await crs.LoadMemberAccounts(members);
 | |
| 
 | |
|             return Ok(members.Where(m => m.Account is not null).ToList());
 | |
|         }
 | |
|     }
 | |
| 
 | |
| 
 | |
|     public class ChatMemberRequest
 | |
|     {
 | |
|         [Required] public Guid RelatedUserId { get; set; }
 | |
|         [Required] public int Role { get; set; }
 | |
|     }
 | |
| 
 | |
|     [HttpPost("invites/{roomId:guid}")]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult<SnChatMember>> InviteMember(Guid roomId,
 | |
|         [FromBody] ChatMemberRequest request)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | |
|         var accountId = Guid.Parse(currentUser.Id);
 | |
| 
 | |
|         // Get related user account
 | |
|         var relatedUser =
 | |
|             await accounts.GetAccountAsync(new GetAccountRequest { Id = request.RelatedUserId.ToString() });
 | |
|         if (relatedUser == null) return BadRequest("Related user was not found");
 | |
| 
 | |
|         // Check if the user has blocked the current user
 | |
|         var relationship = await accounts.GetRelationshipAsync(new GetRelationshipRequest
 | |
|         {
 | |
|             AccountId = currentUser.Id,
 | |
|             RelatedId = relatedUser.Id,
 | |
|             Status = -100
 | |
|         });
 | |
| 
 | |
|         if (relationship?.Relationship != null && relationship.Relationship.Status == -100)
 | |
|             return StatusCode(403, "You cannot invite a user that blocked you.");
 | |
| 
 | |
|         var chatRoom = await db.ChatRooms
 | |
|             .Where(p => p.Id == roomId)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (chatRoom is null) return NotFound();
 | |
| 
 | |
|         // Handle realm-owned chat rooms
 | |
|         if (chatRoom.RealmId is not null)
 | |
|         {
 | |
|             if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, accountId, [RealmMemberRole.Moderator]))
 | |
|                 return StatusCode(403, "You need at least be a realm moderator to invite members to this chat.");
 | |
|         }
 | |
|         else
 | |
|         {
 | |
|             var chatMember = await db.ChatMembers
 | |
|                 .Where(m => m.AccountId == accountId)
 | |
|                 .Where(m => m.ChatRoomId == roomId)
 | |
|                 .Where(m => m.JoinedAt != null && m.LeaveAt == null)
 | |
|                 .FirstOrDefaultAsync();
 | |
|             if (chatMember is null) return StatusCode(403, "You are not even a member of the targeted chat room.");
 | |
|             if (chatMember.Role < ChatMemberRole.Moderator)
 | |
|                 return StatusCode(403,
 | |
|                     "You need at least be a moderator to invite other members to this chat room.");
 | |
|             if (chatMember.Role < request.Role)
 | |
|                 return StatusCode(403, "You cannot invite member with higher permission than yours.");
 | |
|         }
 | |
| 
 | |
|         var existingMember = await db.ChatMembers
 | |
|             .Where(m => m.AccountId == request.RelatedUserId)
 | |
|             .Where(m => m.ChatRoomId == roomId)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (existingMember != null)
 | |
|         {
 | |
|             if (existingMember.LeaveAt == null)
 | |
|                 return BadRequest("This user has been joined the chat cannot be invited again.");
 | |
| 
 | |
|             existingMember.LeaveAt = null;
 | |
|             existingMember.JoinedAt = null;
 | |
|             db.ChatMembers.Update(existingMember);
 | |
|             await db.SaveChangesAsync();
 | |
|             await _SendInviteNotify(existingMember, currentUser);
 | |
| 
 | |
|             _ = als.CreateActionLogAsync(new CreateActionLogRequest
 | |
|             {
 | |
|                 Action = "chatrooms.invite",
 | |
|                 Meta =
 | |
|             {
 | |
|                 { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) },
 | |
|                 { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(relatedUser.Id.ToString()) }
 | |
|             },
 | |
|                 AccountId = currentUser.Id,
 | |
|                 UserAgent = Request.Headers.UserAgent,
 | |
|                 IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
 | |
|             });
 | |
| 
 | |
|             return Ok(existingMember);
 | |
|         }
 | |
| 
 | |
|         var newMember = new SnChatMember
 | |
|         {
 | |
|             AccountId = Guid.Parse(relatedUser.Id),
 | |
|             ChatRoomId = roomId,
 | |
|             Role = request.Role,
 | |
|         };
 | |
| 
 | |
|         db.ChatMembers.Add(newMember);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         newMember.ChatRoom = chatRoom;
 | |
|         await _SendInviteNotify(newMember, currentUser);
 | |
| 
 | |
|         _ = als.CreateActionLogAsync(new CreateActionLogRequest
 | |
|         {
 | |
|             Action = "chatrooms.invite",
 | |
|             Meta =
 | |
|             {
 | |
|                 { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(chatRoom.Id.ToString()) },
 | |
|                 { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(relatedUser.Id.ToString()) }
 | |
|             },
 | |
|             AccountId = currentUser.Id,
 | |
|             UserAgent = Request.Headers.UserAgent,
 | |
|             IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
 | |
|         });
 | |
| 
 | |
|         return Ok(newMember);
 | |
|     }
 | |
| 
 | |
|     [HttpGet("invites")]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult<List<SnChatMember>>> ListChatInvites()
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser) return Unauthorized();
 | |
|         var accountId = Guid.Parse(currentUser.Id);
 | |
| 
 | |
|         var members = await db.ChatMembers
 | |
|             .Where(m => m.AccountId == accountId)
 | |
|             .Where(m => m.JoinedAt == null)
 | |
|             .Include(e => e.ChatRoom)
 | |
|             .ToListAsync();
 | |
| 
 | |
|         var chatRooms = members.Select(m => m.ChatRoom).ToList();
 | |
|         var directMembers =
 | |
|             (await crs.LoadDirectMessageMembers(chatRooms, accountId)).ToDictionary(c => c.Id, c => c.Members);
 | |
| 
 | |
|         foreach (var member in members.Where(member => member.ChatRoom.Type == ChatRoomType.DirectMessage))
 | |
|             member.ChatRoom.Members = directMembers[member.ChatRoom.Id];
 | |
| 
 | |
|         return Ok(await crs.LoadMemberAccounts(members));
 | |
|     }
 | |
| 
 | |
|     [HttpPost("invites/{roomId:guid}/accept")]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult<SnChatRoom>> AcceptChatInvite(Guid roomId)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | |
|         var accountId = Guid.Parse(currentUser.Id);
 | |
| 
 | |
|         var member = await db.ChatMembers
 | |
|             .Where(m => m.AccountId == accountId)
 | |
|             .Where(m => m.ChatRoomId == roomId)
 | |
|             .Where(m => m.JoinedAt == null)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (member is null) return NotFound();
 | |
| 
 | |
|         member.JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
 | |
|         db.Update(member);
 | |
|         await db.SaveChangesAsync();
 | |
|         _ = crs.PurgeRoomMembersCache(roomId);
 | |
| 
 | |
|         _ = als.CreateActionLogAsync(new CreateActionLogRequest
 | |
|         {
 | |
|             Action = ActionLogType.ChatroomJoin,
 | |
|             Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) } },
 | |
|             AccountId = currentUser.Id,
 | |
|             UserAgent = Request.Headers.UserAgent,
 | |
|             IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
 | |
|         });
 | |
| 
 | |
|         return Ok(member);
 | |
|     }
 | |
| 
 | |
|     [HttpPost("invites/{roomId:guid}/decline")]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult> DeclineChatInvite(Guid roomId)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | |
|         var accountId = Guid.Parse(currentUser.Id);
 | |
| 
 | |
|         var member = await db.ChatMembers
 | |
|             .Where(m => m.AccountId == accountId)
 | |
|             .Where(m => m.ChatRoomId == roomId)
 | |
|             .Where(m => m.JoinedAt == null)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (member is null) return NotFound();
 | |
| 
 | |
|         member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         return NoContent();
 | |
|     }
 | |
| 
 | |
|     public class ChatMemberNotifyRequest
 | |
|     {
 | |
|         public ChatMemberNotify? NotifyLevel { get; set; }
 | |
|         public Instant? BreakUntil { get; set; }
 | |
|     }
 | |
| 
 | |
|     [HttpPatch("{roomId:guid}/members/me/notify")]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult<SnChatMember>> UpdateChatMemberNotify(
 | |
|         Guid roomId,
 | |
|         [FromBody] ChatMemberNotifyRequest request
 | |
|     )
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | |
| 
 | |
|         var chatRoom = await db.ChatRooms
 | |
|             .Where(r => r.Id == roomId)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (chatRoom is null) return NotFound();
 | |
| 
 | |
|         var accountId = Guid.Parse(currentUser.Id);
 | |
|         var targetMember = await db.ChatMembers
 | |
|             .Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (targetMember is null) return BadRequest("You have not joined this chat room.");
 | |
|         if (request.NotifyLevel is not null)
 | |
|             targetMember.Notify = request.NotifyLevel.Value;
 | |
|         if (request.BreakUntil is not null)
 | |
|             targetMember.BreakUntil = request.BreakUntil.Value;
 | |
| 
 | |
|         db.ChatMembers.Update(targetMember);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         await crs.PurgeRoomMembersCache(roomId);
 | |
| 
 | |
|         return Ok(targetMember);
 | |
|     }
 | |
| 
 | |
|     [HttpPatch("{roomId:guid}/members/{memberId:guid}/role")]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult<SnChatMember>> UpdateChatMemberRole(Guid roomId, Guid memberId, [FromBody] int newRole)
 | |
|     {
 | |
|         if (newRole >= ChatMemberRole.Owner) return BadRequest("Unable to set chat member to owner or greater role.");
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | |
| 
 | |
|         var chatRoom = await db.ChatRooms
 | |
|             .Where(r => r.Id == roomId)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (chatRoom is null) return NotFound();
 | |
| 
 | |
|         // Check if the chat room is owned by a realm
 | |
|         if (chatRoom.RealmId is not null)
 | |
|         {
 | |
|             if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id), [RealmMemberRole.Moderator]))
 | |
|                 return StatusCode(403, "You need at least be a realm moderator to change member roles.");
 | |
|         }
 | |
|         else
 | |
|         {
 | |
|             var targetMember = await db.ChatMembers
 | |
|                 .Where(m => m.AccountId == memberId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
 | |
|                 .FirstOrDefaultAsync();
 | |
|             if (targetMember is null) return NotFound();
 | |
| 
 | |
|             // Check if the current user has permission to change roles
 | |
|             if (
 | |
|                 !await crs.IsMemberWithRole(
 | |
|                     chatRoom.Id,
 | |
|                     Guid.Parse(currentUser.Id),
 | |
|                     ChatMemberRole.Moderator,
 | |
|                     targetMember.Role,
 | |
|                     newRole
 | |
|                 )
 | |
|             )
 | |
|                 return StatusCode(403, "You don't have enough permission to edit the roles of members.");
 | |
| 
 | |
|             targetMember.Role = newRole;
 | |
|             db.ChatMembers.Update(targetMember);
 | |
|             await db.SaveChangesAsync();
 | |
| 
 | |
|             await crs.PurgeRoomMembersCache(roomId);
 | |
| 
 | |
|             _ = als.CreateActionLogAsync(new CreateActionLogRequest
 | |
|             {
 | |
|                 Action = "chatrooms.role.edit",
 | |
|                 Meta =
 | |
|                 {
 | |
|                     { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) },
 | |
|                     { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(memberId.ToString()) },
 | |
|                     { "new_role", Google.Protobuf.WellKnownTypes.Value.ForNumber(newRole) }
 | |
|                 },
 | |
|                 AccountId = currentUser.Id,
 | |
|                 UserAgent = Request.Headers.UserAgent,
 | |
|                 IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
 | |
|             });
 | |
| 
 | |
|             return Ok(targetMember);
 | |
|         }
 | |
| 
 | |
|         return BadRequest();
 | |
|     }
 | |
| 
 | |
|     [HttpDelete("{roomId:guid}/members/{memberId:guid}")]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult> RemoveChatMember(Guid roomId, Guid memberId)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | |
| 
 | |
|         var chatRoom = await db.ChatRooms
 | |
|             .Where(r => r.Id == roomId)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (chatRoom is null) return NotFound();
 | |
| 
 | |
|         // Check if the chat room is owned by a realm
 | |
|         if (chatRoom.RealmId is not null)
 | |
|         {
 | |
|         if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, Guid.Parse(currentUser.Id),
 | |
|                 [RealmMemberRole.Moderator]))
 | |
|                 return StatusCode(403, "You need at least be a realm moderator to remove members.");
 | |
|         }
 | |
|         else
 | |
|         {
 | |
|             if (!await crs.IsMemberWithRole(chatRoom.Id, Guid.Parse(currentUser.Id), [ChatMemberRole.Moderator]))
 | |
|                 return StatusCode(403, "You need at least be a moderator to remove members.");
 | |
|         }
 | |
| 
 | |
|         // Find the target member
 | |
|         var member = await db.ChatMembers
 | |
|             .Where(m => m.AccountId == memberId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (member is null) return NotFound();
 | |
| 
 | |
|         // Check if the current user has sufficient permissions
 | |
|         if (!await crs.IsMemberWithRole(chatRoom.Id, memberId, member.Role))
 | |
|             return StatusCode(403, "You cannot remove members with equal or higher roles.");
 | |
| 
 | |
|         member.LeaveAt = SystemClock.Instance.GetCurrentInstant();
 | |
|         await db.SaveChangesAsync();
 | |
|         _ = crs.PurgeRoomMembersCache(roomId);
 | |
| 
 | |
|         _ = als.CreateActionLogAsync(new CreateActionLogRequest
 | |
|         {
 | |
|             Action = "chatrooms.kick",
 | |
|             Meta =
 | |
|             {
 | |
|                 { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) },
 | |
|                 { "account_id", Google.Protobuf.WellKnownTypes.Value.ForString(memberId.ToString()) }
 | |
|             },
 | |
|             AccountId = currentUser.Id,
 | |
|             UserAgent = Request.Headers.UserAgent,
 | |
|             IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
 | |
|         });
 | |
| 
 | |
|         return NoContent();
 | |
|     }
 | |
| 
 | |
| 
 | |
|     [HttpPost("{roomId:guid}/members/me")]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult<SnChatRoom>> JoinChatRoom(Guid roomId)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | |
| 
 | |
|         var chatRoom = await db.ChatRooms
 | |
|             .Where(r => r.Id == roomId)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (chatRoom is null) return NotFound();
 | |
|         if (!chatRoom.IsCommunity)
 | |
|             return StatusCode(403, "This chat room isn't a community. You need an invitation to join.");
 | |
| 
 | |
|         var existingMember = await db.ChatMembers
 | |
|             .FirstOrDefaultAsync(m => m.AccountId == Guid.Parse(currentUser.Id) && m.ChatRoomId == roomId);
 | |
|         if (existingMember != null)
 | |
|         {
 | |
|             if (existingMember.LeaveAt == null)
 | |
|                 return BadRequest("You are already a member of this chat room.");
 | |
| 
 | |
|             existingMember.LeaveAt = null;
 | |
|             db.Update(existingMember);
 | |
|             await db.SaveChangesAsync();
 | |
|             _ = crs.PurgeRoomMembersCache(roomId);
 | |
| 
 | |
|             return Ok(existingMember);
 | |
|         }
 | |
| 
 | |
|         var newMember = new SnChatMember
 | |
|         {
 | |
|             AccountId = Guid.Parse(currentUser.Id),
 | |
|             ChatRoomId = roomId,
 | |
|             Role = ChatMemberRole.Member,
 | |
|             JoinedAt = NodaTime.Instant.FromDateTimeUtc(DateTime.UtcNow)
 | |
|         };
 | |
| 
 | |
|         db.ChatMembers.Add(newMember);
 | |
|         await db.SaveChangesAsync();
 | |
|         _ = crs.PurgeRoomMembersCache(roomId);
 | |
| 
 | |
|         _ = als.CreateActionLogAsync(new CreateActionLogRequest
 | |
|         {
 | |
|             Action = ActionLogType.ChatroomJoin,
 | |
|             Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) } },
 | |
|             AccountId = currentUser.Id,
 | |
|             UserAgent = Request.Headers.UserAgent,
 | |
|             IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
 | |
|         });
 | |
| 
 | |
|         return Ok(chatRoom);
 | |
|     }
 | |
| 
 | |
|     [HttpDelete("{roomId:guid}/members/me")]
 | |
|     [Authorize]
 | |
|     public async Task<ActionResult> LeaveChat(Guid roomId)
 | |
|     {
 | |
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
 | |
| 
 | |
|         var member = await db.ChatMembers
 | |
|             .Where(m => m.JoinedAt != null && m.LeaveAt == null)
 | |
|             .Where(m => m.AccountId == Guid.Parse(currentUser.Id))
 | |
|             .Where(m => m.ChatRoomId == roomId)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (member is null) return NotFound();
 | |
| 
 | |
|         if (member.Role == ChatMemberRole.Owner)
 | |
|         {
 | |
|             // Check if this is the only owner
 | |
|             var otherOwners = await db.ChatMembers
 | |
|                 .Where(m => m.ChatRoomId == roomId)
 | |
|                 .Where(m => m.Role == ChatMemberRole.Owner)
 | |
|                 .Where(m => m.AccountId != Guid.Parse(currentUser.Id))
 | |
|                 .AnyAsync();
 | |
| 
 | |
|             if (!otherOwners)
 | |
|                 return BadRequest("The last owner cannot leave the chat. Transfer ownership first or delete the chat.");
 | |
|         }
 | |
| 
 | |
|         member.LeaveAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
 | |
|         db.Update(member);
 | |
|         await db.SaveChangesAsync();
 | |
|         await crs.PurgeRoomMembersCache(roomId);
 | |
| 
 | |
|         _ = als.CreateActionLogAsync(new CreateActionLogRequest
 | |
|         {
 | |
|             Action = ActionLogType.ChatroomLeave,
 | |
|             Meta = { { "chatroom_id", Google.Protobuf.WellKnownTypes.Value.ForString(roomId.ToString()) } },
 | |
|             AccountId = currentUser.Id,
 | |
|             UserAgent = Request.Headers.UserAgent,
 | |
|             IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString()
 | |
|         });
 | |
| 
 | |
|         return NoContent();
 | |
|     }
 | |
| 
 | |
|     private async Task _SendInviteNotify(SnChatMember member, Account sender)
 | |
|     {
 | |
|         var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
 | |
|         CultureService.SetCultureInfo(account);
 | |
| 
 | |
|         string title = localizer["ChatInviteTitle"];
 | |
| 
 | |
|         string body = member.ChatRoom.Type == ChatRoomType.DirectMessage
 | |
|             ? localizer["ChatInviteDirectBody", sender.Nick]
 | |
|             : localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
 | |
| 
 | |
|         await pusher.SendPushNotificationToUserAsync(
 | |
|             new SendPushNotificationToUserRequest
 | |
|             {
 | |
|                 UserId = account.Id,
 | |
|                 Notification = new PushNotification
 | |
|                 {
 | |
|                     Topic = "invites.chats",
 | |
|                     Title = title,
 | |
|                     Body = body,
 | |
|                     IsSavable = true,
 | |
|                     Meta = GrpcTypeHelper.ConvertObjectToByteString(new
 | |
|                     {
 | |
|                         room_id = member.ChatRoomId
 | |
|                     })
 | |
|                 }
 | |
|             }
 | |
|         );
 | |
|     }
 | |
| }
 |