From e49876e20d52721f5b46b1135476e51d23e50b44 Mon Sep 17 00:00:00 2001 From: ahmed Date: Sun, 17 May 2026 14:33:29 +0300 Subject: [PATCH 1/4] feat: implement toggle behavior for user interests --- .../Localization/Resources.yaml | 8 ++++ .../Endpoints/UserInterestEndpoints.cs | 36 ++++++++++++++++ backend/src/CCE.Api.External/Program.cs | 1 + .../UserInterest/UpsertUserInterestCommand.cs | 8 ++++ .../UpsertUserInterestCommandHandler.cs | 42 +++++++++++++++++++ .../UserInterest/UpsertUserInterestResult.cs | 5 +++ .../Messages/MessageFactory.cs | 1 + .../CCE.Application/Messages/SystemCode.cs | 2 + .../CCE.Application/Messages/SystemCodeMap.cs | 2 + backend/src/CCE.Domain/Identity/User.cs | 15 +++++++ 10 files changed, 120 insertions(+) create mode 100644 backend/src/CCE.Api.External/Endpoints/UserInterestEndpoints.cs create mode 100644 backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommand.cs create mode 100644 backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs create mode 100644 backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestResult.cs diff --git a/backend/src/CCE.Api.Common/Localization/Resources.yaml b/backend/src/CCE.Api.Common/Localization/Resources.yaml index 3bac7e4..31e84ff 100644 --- a/backend/src/CCE.Api.Common/Localization/Resources.yaml +++ b/backend/src/CCE.Api.Common/Localization/Resources.yaml @@ -335,3 +335,11 @@ SCENARIO_NOT_FOUND: TECHNOLOGY_NOT_FOUND: ar: "التقنية غير موجودة" en: "Technology not found" + +INTEREST_NOT_FOUND: + ar: "الاهتمام غير موجود" + en: "Interest not found" + +INTEREST_UPSERTED: + ar: "تم تحديث الاهتمامات بنجاح" + en: "Interests updated successfully" diff --git a/backend/src/CCE.Api.External/Endpoints/UserInterestEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/UserInterestEndpoints.cs new file mode 100644 index 0000000..9352400 --- /dev/null +++ b/backend/src/CCE.Api.External/Endpoints/UserInterestEndpoints.cs @@ -0,0 +1,36 @@ +using CCE.Api.Common.Extensions; +using CCE.Application.Common.Interfaces; +using CCE.Application.Identity.Public.Commands.UserInterest; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CCE.Api.External.Endpoints; + +public static class UserInterestEndpoints +{ + public static IEndpointRouteBuilder MapUserInterestEndpoints(this IEndpointRouteBuilder app) + { + var me = app.MapGroup("/api/me").WithTags("User Interests").RequireAuthorization(); + + me.MapPatch("/interests", async ( + UpsertUserInterestRequest body, + ICurrentUserAccessor currentUser, + IMediator mediator, + CancellationToken ct) => + { + var userId = currentUser.GetUserId() ?? System.Guid.Empty; + if (userId == System.Guid.Empty) return Results.Unauthorized(); + + var result = await mediator.Send( + new UpsertUserInterestCommand(userId, body.Interest), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .WithName("UpsertUserInterest"); + + return app; + } +} + +public sealed record UpsertUserInterestRequest(string Interest); diff --git a/backend/src/CCE.Api.External/Program.cs b/backend/src/CCE.Api.External/Program.cs index f2439ee..3ebb9b7 100644 --- a/backend/src/CCE.Api.External/Program.cs +++ b/backend/src/CCE.Api.External/Program.cs @@ -103,6 +103,7 @@ app.MapAssistantEndpoints(); app.MapKapsarcEndpoints(); app.MapSurveysEndpoints(); +app.MapUserInterestEndpoints(); app.MapGet("/health", async (IMediator mediator) => { diff --git a/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommand.cs b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommand.cs new file mode 100644 index 0000000..77091a4 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommand.cs @@ -0,0 +1,8 @@ +using CCE.Application.Common; +using MediatR; + +namespace CCE.Application.Identity.Public.Commands.UserInterest; + +public sealed record UpsertUserInterestCommand( + System.Guid UserId, + string Interest) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs new file mode 100644 index 0000000..b8410f3 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs @@ -0,0 +1,42 @@ +using CCE.Application.Common; +using CCE.Application.Common.Interfaces; +using CCE.Application.Messages; +using MediatR; + +namespace CCE.Application.Identity.Public.Commands.UserInterest; + +public sealed class UpsertUserInterestCommandHandler + : IRequestHandler> +{ + private readonly IUserProfileRepository _service; + private readonly ICceDbContext _db; + private readonly MessageFactory _msg; + + public UpsertUserInterestCommandHandler( + IUserProfileRepository service, + ICceDbContext db, + MessageFactory msg) + { + _service = service; + _db = db; + _msg = msg; + } + + public async Task> Handle( + UpsertUserInterestCommand request, + CancellationToken cancellationToken) + { + var user = await _service.FindAsync(request.UserId, cancellationToken).ConfigureAwait(false); + if (user is null) + return _msg.UserNotFound(); + + var added = user.ToggleInterest(request.Interest); + + _service.Update(user); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return _msg.InterestUpserted(new UpsertUserInterestResult( + user.Interests, + added)); + } +} diff --git a/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestResult.cs b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestResult.cs new file mode 100644 index 0000000..bf1816d --- /dev/null +++ b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestResult.cs @@ -0,0 +1,5 @@ +namespace CCE.Application.Identity.Public.Commands.UserInterest; + +public sealed record UpsertUserInterestResult( + IReadOnlyList Interests, + bool Added); diff --git a/backend/src/CCE.Application/Messages/MessageFactory.cs b/backend/src/CCE.Application/Messages/MessageFactory.cs index 6ba4868..b1742ce 100644 --- a/backend/src/CCE.Application/Messages/MessageFactory.cs +++ b/backend/src/CCE.Application/Messages/MessageFactory.cs @@ -68,6 +68,7 @@ public FieldError Field(string fieldName, string domainKey) // ─── Convenience shortcuts (Identity domain) ─── public Response UserNotFound() => NotFound("USER_NOT_FOUND"); + public Response InterestUpserted(T data) => Ok(data, "INTEREST_UPSERTED"); public Response EmailExists() => Conflict("EMAIL_EXISTS"); public Response InvalidCredentials() => Unauthorized("INVALID_CREDENTIALS"); public Response NotAuthenticated() => Unauthorized("NOT_AUTHENTICATED"); diff --git a/backend/src/CCE.Application/Messages/SystemCode.cs b/backend/src/CCE.Application/Messages/SystemCode.cs index beb7aab..da99ec1 100644 --- a/backend/src/CCE.Application/Messages/SystemCode.cs +++ b/backend/src/CCE.Application/Messages/SystemCode.cs @@ -21,6 +21,7 @@ public static class SystemCode public const string ERR002 = "ERR002"; // Resource download failure (appendix) public const string ERR003 = "ERR003"; // Resource share failure (appendix) + public const string ERR018 = "ERR018"; // Interest not found public const string ERR019 = "ERR019"; // Email already exists / Account creation failure (appendix) public const string ERR020 = "ERR020"; // Invalid credentials (appendix) public const string ERR021 = "ERR021"; // Login system error (appendix) @@ -115,6 +116,7 @@ public static class SystemCode public const string CON007 = "CON007"; // Admin notified of expert request (appendix) public const string CON008 = "CON008"; // Service evaluation submitted (appendix) public const string CON009 = "CON009"; // Personalized suggestions submitted (appendix) + public const string CON018 = "CON018"; // Interest upserted public const string CON010 = "CON010"; // Topic follow success (appendix) public const string CON011 = "CON011"; // Post created (appendix) public const string CON012 = "CON012"; // Post follow success (appendix) diff --git a/backend/src/CCE.Application/Messages/SystemCodeMap.cs b/backend/src/CCE.Application/Messages/SystemCodeMap.cs index f53ae1a..c5f4d60 100644 --- a/backend/src/CCE.Application/Messages/SystemCodeMap.cs +++ b/backend/src/CCE.Application/Messages/SystemCodeMap.cs @@ -14,6 +14,7 @@ public static class SystemCodeMap ["INVALID_CREDENTIALS"] = SystemCode.ERR020, ["PASSWORD_RECOVERY_FAILED"] = SystemCode.ERR023, ["LOGOUT_FAILED"] = SystemCode.ERR024, + ["INTEREST_NOT_FOUND"] = SystemCode.ERR018, // ─── Backend-only Identity Errors (moved to free appendix numbers) ─── ["EXPERT_REQUEST_NOT_FOUND"] = SystemCode.ERR400, @@ -90,6 +91,7 @@ public static class SystemCodeMap ["PASSWORD_RESET"] = SystemCode.CON014, ["LOGOUT_SUCCESS"] = SystemCode.CON015, ["REGISTER_SUCCESS"] = SystemCode.CON017, + ["INTEREST_UPSERTED"] = SystemCode.CON018, // ─── Backend-only Identity Success (appendix numbers already taken) ─── ["EXPERT_REQUEST_APPROVED"] = SystemCode.CON050, diff --git a/backend/src/CCE.Domain/Identity/User.cs b/backend/src/CCE.Domain/Identity/User.cs index 5cdd1e0..4c95b18 100644 --- a/backend/src/CCE.Domain/Identity/User.cs +++ b/backend/src/CCE.Domain/Identity/User.cs @@ -144,6 +144,21 @@ public void UpdateInterests(IEnumerable interests) .ToList(); } + /// + /// Toggles an interest. If it exists it is removed; otherwise it is added. + /// Returns true if added, false if removed. + /// + public bool ToggleInterest(string interest) + { + if (string.IsNullOrWhiteSpace(interest)) + throw new DomainException("Interest cannot be null or empty."); + var trimmed = interest.Trim(); + if (Interests.Remove(trimmed)) + return false; + Interests.Add(trimmed); + return true; + } + public void AssignCountry(System.Guid countryId) => CountryId = countryId; public void ClearCountry() => CountryId = null; From ed2e61f3eac2bd9b924ec0ba868cf7c19d9ac973 Mon Sep 17 00:00:00 2001 From: ahmed Date: Sun, 17 May 2026 18:32:24 +0300 Subject: [PATCH 2/4] feat: add user interest toggle endpoint --- .../Endpoints/UserInterestEndpoints.cs | 4 ++-- .../UserInterest/UpsertUserInterestCommand.cs | 2 +- .../UpsertUserInterestCommandHandler.cs | 15 ++++++++++++--- .../UserInterest/UpsertUserInterestResult.cs | 3 ++- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/backend/src/CCE.Api.External/Endpoints/UserInterestEndpoints.cs b/backend/src/CCE.Api.External/Endpoints/UserInterestEndpoints.cs index 9352400..817ecab 100644 --- a/backend/src/CCE.Api.External/Endpoints/UserInterestEndpoints.cs +++ b/backend/src/CCE.Api.External/Endpoints/UserInterestEndpoints.cs @@ -24,7 +24,7 @@ public static IEndpointRouteBuilder MapUserInterestEndpoints(this IEndpointRoute if (userId == System.Guid.Empty) return Results.Unauthorized(); var result = await mediator.Send( - new UpsertUserInterestCommand(userId, body.Interest), ct).ConfigureAwait(false); + new UpsertUserInterestCommand(userId, body.Interests), ct).ConfigureAwait(false); return result.ToHttpResult(); }) .WithName("UpsertUserInterest"); @@ -33,4 +33,4 @@ public static IEndpointRouteBuilder MapUserInterestEndpoints(this IEndpointRoute } } -public sealed record UpsertUserInterestRequest(string Interest); +public sealed record UpsertUserInterestRequest(IReadOnlyList Interests); diff --git a/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommand.cs b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommand.cs index 77091a4..10d74bb 100644 --- a/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommand.cs +++ b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommand.cs @@ -5,4 +5,4 @@ namespace CCE.Application.Identity.Public.Commands.UserInterest; public sealed record UpsertUserInterestCommand( System.Guid UserId, - string Interest) : IRequest>; + IReadOnlyList Interests) : IRequest>; diff --git a/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs index b8410f3..1bcde5d 100644 --- a/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs @@ -1,3 +1,4 @@ +using System.Linq; using CCE.Application.Common; using CCE.Application.Common.Interfaces; using CCE.Application.Messages; @@ -30,13 +31,21 @@ public async Task> Handle( if (user is null) return _msg.UserNotFound(); - var added = user.ToggleInterest(request.Interest); + var oldInterests = user.Interests.ToList(); + var newList = request.Interests ?? System.Array.Empty(); + + user.UpdateInterests(newList); + + var newInterests = user.Interests; + var added = newInterests.Except(oldInterests).ToList(); + var removed = oldInterests.Except(newInterests).ToList(); _service.Update(user); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return _msg.InterestUpserted(new UpsertUserInterestResult( - user.Interests, - added)); + newInterests, + added, + removed)); } } diff --git a/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestResult.cs b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestResult.cs index bf1816d..8e55310 100644 --- a/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestResult.cs +++ b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestResult.cs @@ -2,4 +2,5 @@ namespace CCE.Application.Identity.Public.Commands.UserInterest; public sealed record UpsertUserInterestResult( IReadOnlyList Interests, - bool Added); + IReadOnlyList Added, + IReadOnlyList Removed); From fce5967b265acdec85087794d4d1e05fe0e95502 Mon Sep 17 00:00:00 2001 From: ahmed Date: Mon, 18 May 2026 12:13:05 +0300 Subject: [PATCH 3/4] delete Toggle Interests user --- backend/src/CCE.Domain/Identity/User.cs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/backend/src/CCE.Domain/Identity/User.cs b/backend/src/CCE.Domain/Identity/User.cs index 4c95b18..5cdd1e0 100644 --- a/backend/src/CCE.Domain/Identity/User.cs +++ b/backend/src/CCE.Domain/Identity/User.cs @@ -144,21 +144,6 @@ public void UpdateInterests(IEnumerable interests) .ToList(); } - /// - /// Toggles an interest. If it exists it is removed; otherwise it is added. - /// Returns true if added, false if removed. - /// - public bool ToggleInterest(string interest) - { - if (string.IsNullOrWhiteSpace(interest)) - throw new DomainException("Interest cannot be null or empty."); - var trimmed = interest.Trim(); - if (Interests.Remove(trimmed)) - return false; - Interests.Add(trimmed); - return true; - } - public void AssignCountry(System.Guid countryId) => CountryId = countryId; public void ClearCountry() => CountryId = null; From f7864f559992c6b77168e4f0a984d5405c8e037a Mon Sep 17 00:00:00 2001 From: ahmed Date: Mon, 18 May 2026 12:34:14 +0300 Subject: [PATCH 4/4] UpsertUserInterest update hte unnessesary wrote in db --- .../UpsertUserInterestCommandHandler.cs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs index 1bcde5d..6acd734 100644 --- a/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs +++ b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs @@ -32,19 +32,35 @@ public async Task> Handle( return _msg.UserNotFound(); var oldInterests = user.Interests.ToList(); - var newList = request.Interests ?? System.Array.Empty(); + var rawList = request.Interests ?? System.Array.Empty(); - user.UpdateInterests(newList); + var normalizedNew = rawList + .Select(static s => s?.Trim() ?? string.Empty) + .Where(static s => s.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); - var newInterests = user.Interests; - var added = newInterests.Except(oldInterests).ToList(); - var removed = oldInterests.Except(newInterests).ToList(); + var oldSet = new HashSet(oldInterests, StringComparer.OrdinalIgnoreCase); + var newSet = new HashSet(normalizedNew, StringComparer.OrdinalIgnoreCase); + + if (oldSet.SetEquals(newSet)) + { + return _msg.InterestUpserted(new UpsertUserInterestResult( + user.Interests, + System.Array.Empty(), + System.Array.Empty())); + } + + user.UpdateInterests(normalizedNew); + + var added = normalizedNew.Except(oldInterests, StringComparer.OrdinalIgnoreCase).ToList(); + var removed = oldInterests.Except(normalizedNew, StringComparer.OrdinalIgnoreCase).ToList(); _service.Update(user); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return _msg.InterestUpserted(new UpsertUserInterestResult( - newInterests, + user.Interests, added, removed)); }