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..817ecab --- /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.Interests), ct).ConfigureAwait(false); + return result.ToHttpResult(); + }) + .WithName("UpsertUserInterest"); + + return app; + } +} + +public sealed record UpsertUserInterestRequest(IReadOnlyList Interests); 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..10d74bb --- /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, + 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 new file mode 100644 index 0000000..6acd734 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestCommandHandler.cs @@ -0,0 +1,67 @@ +using System.Linq; +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 oldInterests = user.Interests.ToList(); + var rawList = request.Interests ?? System.Array.Empty(); + + var normalizedNew = rawList + .Select(static s => s?.Trim() ?? string.Empty) + .Where(static s => s.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .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( + user.Interests, + 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 new file mode 100644 index 0000000..8e55310 --- /dev/null +++ b/backend/src/CCE.Application/Identity/Public/Commands/UserInterest/UpsertUserInterestResult.cs @@ -0,0 +1,6 @@ +namespace CCE.Application.Identity.Public.Commands.UserInterest; + +public sealed record UpsertUserInterestResult( + IReadOnlyList Interests, + IReadOnlyList Added, + IReadOnlyList Removed); 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,