diff --git a/DysonNetwork.Pass/Account/AccountServiceGrpc.cs b/DysonNetwork.Pass/Account/AccountServiceGrpc.cs index c94ae1d..1642ed4 100644 --- a/DysonNetwork.Pass/Account/AccountServiceGrpc.cs +++ b/DysonNetwork.Pass/Account/AccountServiceGrpc.cs @@ -56,68 +56,6 @@ public class AccountServiceGrpc( return response; } - public override async Task CreateAccount(CreateAccountRequest request, - ServerCallContext context) - { - // Map protobuf request to domain model - var account = new Account - { - Name = request.Name, - Nick = request.Nick, - Language = request.Language, - IsSuperuser = request.IsSuperuser, - ActivatedAt = request.Profile != null ? null : _clock.GetCurrentInstant(), - Profile = new AccountProfile - { - FirstName = request.Profile?.FirstName, - LastName = request.Profile?.LastName, - // Initialize other profile fields as needed - } - }; - - // Add to database - _db.Accounts.Add(account); - await _db.SaveChangesAsync(); - - _logger.LogInformation("Created new account with ID {AccountId}", account.Id); - return account.ToProtoValue(); - } - - public override async Task UpdateAccount(UpdateAccountRequest request, - ServerCallContext context) - { - if (!Guid.TryParse(request.Id, out var accountId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); - - var account = await _db.Accounts.FindAsync(accountId); - if (account == null) - throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found")); - - // Update fields if they are provided in the request - if (request.Name != null) account.Name = request.Name; - if (request.Nick != null) account.Nick = request.Nick; - if (request.Language != null) account.Language = request.Language; - if (request.IsSuperuser != null) account.IsSuperuser = request.IsSuperuser.Value; - - await _db.SaveChangesAsync(); - return account.ToProtoValue(); - } - - public override async Task DeleteAccount(DeleteAccountRequest request, ServerCallContext context) - { - if (!Guid.TryParse(request.Id, out var accountId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); - - var account = await _db.Accounts.FindAsync(accountId); - if (account == null) - throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found")); - - _db.Accounts.Remove(account); - - await _db.SaveChangesAsync(); - return new Empty(); - } - public override async Task ListAccounts(ListAccountsRequest request, ServerCallContext context) { @@ -161,210 +99,6 @@ public class AccountServiceGrpc( return response; } -// Implement other service methods following the same pattern... - -// Profile operations - public override async Task GetProfile(GetProfileRequest request, - ServerCallContext context) - { - if (!Guid.TryParse(request.AccountId, out var accountId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); - - var profile = await _db.AccountProfiles - .AsNoTracking() - .FirstOrDefaultAsync(p => p.AccountId == accountId); - - if (profile == null) - throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, - $"Profile for account {request.AccountId} not found")); - - return profile.ToProtoValue(); - } - - public override async Task UpdateProfile(UpdateProfileRequest request, - ServerCallContext context) - { - if (!Guid.TryParse(request.AccountId, out var accountId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); - - var profile = await _db.AccountProfiles - .FirstOrDefaultAsync(p => p.AccountId == accountId); - - if (profile == null) - throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, - $"Profile for account {request.AccountId} not found")); - - // Update only the fields specified in the field mask - if (request.UpdateMask == null || request.UpdateMask.Paths.Contains("first_name")) - profile.FirstName = request.Profile.FirstName; - - if (request.UpdateMask == null || request.UpdateMask.Paths.Contains("last_name")) - profile.LastName = request.Profile.LastName; - - // Update other fields similarly... - - await _db.SaveChangesAsync(); - return profile.ToProtoValue(); - } - -// Contact operations - public override async Task AddContact(AddContactRequest request, - ServerCallContext context) - { - if (!Guid.TryParse(request.AccountId, out var accountId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); - - var contact = new AccountContact - { - AccountId = accountId, - Type = (AccountContactType)request.Type, - Content = request.Content, - IsPrimary = request.IsPrimary, - VerifiedAt = null - }; - - _db.AccountContacts.Add(contact); - await _db.SaveChangesAsync(); - - return contact.ToProtoValue(); - } - - public override async Task RemoveContact(RemoveContactRequest request, ServerCallContext context) - { - if (!Guid.TryParse(request.AccountId, out var accountId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); - - if (!Guid.TryParse(request.Id, out var contactId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid contact ID format")); - - var contact = await _db.AccountContacts.FirstOrDefaultAsync(c => c.Id == contactId && c.AccountId == accountId); - if (contact == null) - throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Contact not found.")); - - _db.AccountContacts.Remove(contact); - await _db.SaveChangesAsync(); - - return new Empty(); - } - - public override async Task ListContacts(ListContactsRequest request, - ServerCallContext context) - { - if (!Guid.TryParse(request.AccountId, out var accountId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); - - var query = _db.AccountContacts.AsNoTracking().Where(c => c.AccountId == accountId); - - if (request.VerifiedOnly) - query = query.Where(c => c.VerifiedAt != null); - - var contacts = await query.ToListAsync(); - - var response = new ListContactsResponse(); - response.Contacts.AddRange(contacts.Select(c => c.ToProtoValue())); - - return response; - } - - public override async Task VerifyContact(VerifyContactRequest request, - ServerCallContext context) - { - // This is a placeholder implementation. In a real-world scenario, you would - // have a more robust verification mechanism (e.g., sending a code to the - // user's email or phone). - if (!Guid.TryParse(request.AccountId, out var accountId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); - - if (!Guid.TryParse(request.Id, out var contactId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid contact ID format")); - - var contact = await _db.AccountContacts.FirstOrDefaultAsync(c => c.Id == contactId && c.AccountId == accountId); - if (contact == null) - throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Contact not found.")); - - contact.VerifiedAt = _clock.GetCurrentInstant(); - await _db.SaveChangesAsync(); - - return contact.ToProtoValue(); - } - -// Badge operations - public override async Task AddBadge(AddBadgeRequest request, ServerCallContext context) - { - if (!Guid.TryParse(request.AccountId, out var accountId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); - - var badge = new AccountBadge - { - AccountId = accountId, - Type = request.Type, - Label = request.Label, - Caption = request.Caption, - ActivatedAt = _clock.GetCurrentInstant(), - ExpiredAt = request.ExpiredAt?.ToInstant(), - Meta = request.Meta.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value) - }; - - _db.Badges.Add(badge); - await _db.SaveChangesAsync(); - - return badge.ToProtoValue(); - } - - public override async Task RemoveBadge(RemoveBadgeRequest request, ServerCallContext context) - { - if (!Guid.TryParse(request.AccountId, out var accountId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); - - if (!Guid.TryParse(request.Id, out var badgeId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid badge ID format")); - - var badge = await _db.Badges.FirstOrDefaultAsync(b => b.Id == badgeId && b.AccountId == accountId); - if (badge == null) - throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Badge not found.")); - - _db.Badges.Remove(badge); - await _db.SaveChangesAsync(); - - return new Empty(); - } - - public override async Task ListBadges(ListBadgesRequest request, ServerCallContext context) - { - if (!Guid.TryParse(request.AccountId, out var accountId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); - - var query = _db.Badges.AsNoTracking().Where(b => b.AccountId == accountId); - - if (request.ActiveOnly) - query = query.Where(b => b.ExpiredAt == null || b.ExpiredAt > _clock.GetCurrentInstant()); - - var badges = await query.ToListAsync(); - - var response = new ListBadgesResponse(); - response.Badges.AddRange(badges.Select(b => b.ToProtoValue())); - - return response; - } - - public override async Task SetActiveBadge(SetActiveBadgeRequest request, - ServerCallContext context) - { - if (!Guid.TryParse(request.AccountId, out var accountId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format")); - - var profile = await _db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == accountId); - if (profile == null) - throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Profile not found.")); - - if (!string.IsNullOrEmpty(request.BadgeId) && !Guid.TryParse(request.BadgeId, out var badgeId)) - throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid badge ID format")); - - await _db.SaveChangesAsync(); - - return profile.ToProtoValue(); - } - public override async Task ListFriends( ListUserRelationshipSimpleRequest request, ServerCallContext context) { diff --git a/DysonNetwork.Pass/Account/ActionLog.cs b/DysonNetwork.Pass/Account/ActionLog.cs index 32f0c3f..904064f 100644 --- a/DysonNetwork.Pass/Account/ActionLog.cs +++ b/DysonNetwork.Pass/Account/ActionLog.cs @@ -1,6 +1,8 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using DysonNetwork.Shared.Data; +using DysonNetwork.Shared.Proto; +using NodaTime.Serialization.Protobuf; using Point = NetTopologySuite.Geometries.Point; namespace DysonNetwork.Pass.Account; @@ -56,4 +58,26 @@ public class ActionLog : ModelBase public Guid AccountId { get; set; } public Account Account { get; set; } = null!; public Guid? SessionId { get; set; } + + public Shared.Proto.ActionLog ToProtoValue() + { + var protoLog = new Shared.Proto.ActionLog + { + Id = Id.ToString(), + Action = Action, + UserAgent = UserAgent ?? string.Empty, + IpAddress = IpAddress ?? string.Empty, + Location = Location?.ToString() ?? string.Empty, + AccountId = AccountId.ToString(), + CreatedAt = CreatedAt.ToTimestamp() + }; + + // Convert Meta dictionary to Struct + protoLog.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta)); + + if (SessionId.HasValue) + protoLog.SessionId = SessionId.Value.ToString(); + + return protoLog; + } } \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/ActionLogService.cs b/DysonNetwork.Pass/Account/ActionLogService.cs index 2dff230..3860748 100644 --- a/DysonNetwork.Pass/Account/ActionLogService.cs +++ b/DysonNetwork.Pass/Account/ActionLogService.cs @@ -5,7 +5,7 @@ namespace DysonNetwork.Pass.Account; public class ActionLogService(GeoIpService geo, FlushBufferService fbs) { - public void CreateActionLog(Guid accountId, string action, Dictionary meta) + public void CreateActionLog(Guid accountId, string action, Dictionary meta) { var log = new ActionLog { diff --git a/DysonNetwork.Pass/Account/ActionLogServiceGrpc.cs b/DysonNetwork.Pass/Account/ActionLogServiceGrpc.cs new file mode 100644 index 0000000..e81f2b6 --- /dev/null +++ b/DysonNetwork.Pass/Account/ActionLogServiceGrpc.cs @@ -0,0 +1,114 @@ +using DysonNetwork.Shared.Proto; +using Grpc.Core; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Account; + +public class ActionLogServiceGrpc : Shared.Proto.ActionLogService.ActionLogServiceBase +{ + private readonly ActionLogService _actionLogService; + private readonly AppDatabase _db; + private readonly ILogger _logger; + + public ActionLogServiceGrpc( + ActionLogService actionLogService, + AppDatabase db, + ILogger logger) + { + _actionLogService = actionLogService ?? throw new ArgumentNullException(nameof(actionLogService)); + _db = db ?? throw new ArgumentNullException(nameof(db)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public override async Task CreateActionLog(CreateActionLogRequest request, + ServerCallContext context) + { + if (string.IsNullOrEmpty(request.AccountId) || !Guid.TryParse(request.AccountId, out var accountId)) + { + throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument, "Invalid account ID")); + } + + try + { + var meta = request.Meta + ?.Select(x => new KeyValuePair(x.Key, GrpcTypeHelper.ConvertField(x.Value))) + .ToDictionary() ?? new Dictionary(); + + _actionLogService.CreateActionLog( + accountId, + request.Action, + meta + ); + + return new CreateActionLogResponse(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating action log"); + throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.Internal, "Failed to create action log")); + } + } + + public override async Task ListActionLogs(ListActionLogsRequest request, + ServerCallContext context) + { + if (string.IsNullOrEmpty(request.AccountId) || !Guid.TryParse(request.AccountId, out var accountId)) + { + throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument, "Invalid account ID")); + } + + try + { + var query = _db.ActionLogs + .AsNoTracking() + .Where(log => log.AccountId == accountId); + + if (!string.IsNullOrEmpty(request.Action)) + { + query = query.Where(log => log.Action == request.Action); + } + + // Apply ordering (default to newest first) + query = (request.OrderBy?.ToLower() ?? "createdat desc") switch + { + "createdat" => query.OrderBy(log => log.CreatedAt), + "createdat desc" => query.OrderByDescending(log => log.CreatedAt), + _ => query.OrderByDescending(log => log.CreatedAt) + }; + + // Apply pagination + var pageSize = request.PageSize == 0 ? 50 : Math.Min(request.PageSize, 1000); + var logs = await query + .Take(pageSize + 1) // Fetch one extra to determine if there are more pages + .ToListAsync(); + + var hasMore = logs.Count > pageSize; + if (hasMore) + { + logs.RemoveAt(logs.Count - 1); + } + + var response = new ListActionLogsResponse + { + TotalSize = await query.CountAsync() + }; + + if (hasMore) + { + // In a real implementation, you'd generate a proper page token + response.NextPageToken = (logs.LastOrDefault()?.CreatedAt ?? SystemClock.Instance.GetCurrentInstant()) + .ToString(); + } + + response.ActionLogs.AddRange(logs.Select(log => log.ToProtoValue())); + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing action logs"); + throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.Internal, "Failed to list action logs")); + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Startup/ApplicationConfiguration.cs b/DysonNetwork.Pass/Startup/ApplicationConfiguration.cs index 8ee7fa8..df51b15 100644 --- a/DysonNetwork.Pass/Startup/ApplicationConfiguration.cs +++ b/DysonNetwork.Pass/Startup/ApplicationConfiguration.cs @@ -70,6 +70,7 @@ public static class ApplicationConfiguration { app.MapGrpcService(); app.MapGrpcService(); + app.MapGrpcService(); return app; } diff --git a/DysonNetwork.Shared/Proto/account.proto b/DysonNetwork.Shared/Proto/account.proto index c66e304..7aad9c7 100644 --- a/DysonNetwork.Shared/Proto/account.proto +++ b/DysonNetwork.Shared/Proto/account.proto @@ -174,6 +174,19 @@ message LevelingInfo { repeated int32 experience_per_level = 6; } +// ActionLog represents a record of an action taken by a user +message ActionLog { + string id = 1; // Unique identifier for the log entry + string action = 2; // The action that was performed, e.g., "user.login" + map meta = 3; // Metadata associated with the action + google.protobuf.StringValue user_agent = 4; // User agent of the client + google.protobuf.StringValue ip_address = 5; // IP address of the client + google.protobuf.StringValue location = 6; // Geographic location of the client, derived from IP + string account_id = 7; // The account that performed the action + google.protobuf.StringValue session_id = 8; // The session in which the action was performed + google.protobuf.Timestamp created_at = 9; // When the action log was created +} + // ==================================== // Service Definitions // ==================================== @@ -209,10 +222,45 @@ service AccountService { rpc ListBlocked(ListUserRelationshipSimpleRequest) returns (ListUserRelationshipSimpleResponse) {} } +// ActionLogService provides operations for action logs +service ActionLogService { + rpc CreateActionLog(CreateActionLogRequest) returns (CreateActionLogResponse) {} + rpc ListActionLogs(ListActionLogsRequest) returns (ListActionLogsResponse) {} +} + // ==================================== // Request/Response Messages // ==================================== +// ActionLog Requests/Responses +message CreateActionLogRequest { + string action = 1; + map meta = 2; + google.protobuf.StringValue user_agent = 3; + google.protobuf.StringValue ip_address = 4; + google.protobuf.StringValue location = 5; + string account_id = 6; + google.protobuf.StringValue session_id = 7; +} + +message CreateActionLogResponse { + ActionLog action_log = 1; +} + +message ListActionLogsRequest { + string account_id = 1; + string action = 2; + int32 page_size = 3; + string page_token = 4; + string order_by = 5; +} + +message ListActionLogsResponse { + repeated ActionLog action_logs = 1; + string next_page_token = 2; + int32 total_size = 3; +} + // Account Requests/Responses message GetAccountRequest { string id = 1; // Account ID to retrieve diff --git a/DysonNetwork.Sphere/Post/PostController.cs b/DysonNetwork.Sphere/Post/PostController.cs index 7e9dda0..3def48a 100644 --- a/DysonNetwork.Sphere/Post/PostController.cs +++ b/DysonNetwork.Sphere/Post/PostController.cs @@ -1,15 +1,11 @@ using System.ComponentModel.DataAnnotations; -using System.Text.Json; -using DysonNetwork.Sphere.Account; -using DysonNetwork.Sphere.Pages.Posts; +using DysonNetwork.Shared.Proto; using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Publisher; -using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; -using NpgsqlTypes; namespace DysonNetwork.Sphere.Post; @@ -19,8 +15,8 @@ public class PostController( AppDatabase db, PostService ps, PublisherService pub, - RelationshipService rels, - ActionLogService als + AccountService.AccountServiceClient accounts, + ActionLogService.ActionLogServiceClient als ) : ControllerBase {