Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/src/CCE.Api.Common/Localization/Resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -673,3 +673,15 @@ LOOKUP_CREATED:
LOOKUP_UPDATED:
ar: "تم التحديث بنجاح"
en: "Updated successfully"
INTEREST_NOT_FOUND:
ar: "الاهتمام غير موجود"
en: "Interest not found"

INTEREST_UPSERTED:
ar: "تم تحديث الاهتمامات بنجاح"
en: "Interests updated successfully"

INTEREST_TOPIC_NOT_FOUND:
ar: "موضوع الاهتمام غير موجود"
en: "Interest topic not found"

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using CCE.Api.Common.Extensions;
using CCE.Application.InterestManagement.Queries.GetInterestQuestions;
using CCE.Application.InterestManagement.Queries.ListInterestTopics;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace CCE.Api.External.Endpoints;

public static class InterestTopicPublicEndpoints
{
public static IEndpointRouteBuilder MapInterestTopicPublicEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/interest-topics", async (
IMediator mediator, CancellationToken cancellationToken) =>
{
var result = await mediator.Send(new ListInterestTopicsQuery(), cancellationToken).ConfigureAwait(false);
return result.ToHttpResult();
})
.WithName("ListInterestTopicsPublic");

app.MapGet("/api/interest-topics/questions", async (
IMediator mediator, CancellationToken cancellationToken) =>
{
var result = await mediator.Send(new GetInterestQuestionsQuery(), cancellationToken).ConfigureAwait(false);
return result.ToHttpResult();
})
.WithName("GetInterestQuestions");

return app;
}
}
60 changes: 60 additions & 0 deletions backend/src/CCE.Api.External/Endpoints/UserInterestEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using CCE.Api.Common.Extensions;
using CCE.Application.Common.Interfaces;
using CCE.Application.Identity.Public.Commands.UserInterest;
using CCE.Application.Identity.Public.Dtos;
using CCE.Application.Identity.Public.Queries.GetMyInterests;
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.MapGet("/interests", async (
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 GetMyInterestsQuery(userId), ct).ConfigureAwait(false);
return result.ToHttpResult();
})
.WithName("GetMyInterests");

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.CarbonAreaIds,
body.KnowledgeAssessmentId,
body.JobSectorId,
body.TargetCountryId), ct).ConfigureAwait(false);
return result.ToHttpResult();
})
.WithName("UpsertUserInterest");

return app;
}
}

public sealed record UpsertUserInterestRequest(
IReadOnlyList<System.Guid>? CarbonAreaIds,
System.Guid? KnowledgeAssessmentId,
System.Guid? JobSectorId,
System.Guid? TargetCountryId);
10 changes: 6 additions & 4 deletions backend/src/CCE.Api.External/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,12 @@
app.MapAboutSettingsPublicEndpoints();
app.MapPoliciesSettingsPublicEndpoints();
app.MapMediaPublicEndpoints();
app.MapVerificationEndpoints();
app.MapStateRepresentativeEndpoints();
app.MapCountryCodesPublicEndpoints();
app.MapRedisAdminEndpoints();
app.MapVerificationEndpoints();
app.MapStateRepresentativeEndpoints();
app.MapCountryCodesPublicEndpoints();
app.MapRedisAdminEndpoints();
app.MapUserInterestEndpoints();
app.MapInterestTopicPublicEndpoints();

app.MapGet("/health", async (IMediator mediator) =>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ public interface ICceDbContext
// ─── Media ───
IQueryable<MediaFile> MediaFiles { get; }

// ─── Interest Topics ───
IQueryable<InterestTopic> InterestTopics { get; }

// Write operations
void Add<T>(T entity) where T : class;
void Attach<T>(T entity) where T : class;
Expand Down
3 changes: 2 additions & 1 deletion backend/src/CCE.Application/Identity/Dtos/UserDetailDto.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CCE.Application.InterestManagement.Dtos;
using CCE.Domain.Identity;

namespace CCE.Application.Identity.Dtos;
Expand All @@ -11,7 +12,7 @@ public sealed record UserDetailDto(
string? UserName,
string LocalePreference,
KnowledgeLevel KnowledgeLevel,
IReadOnlyList<string> Interests,
IReadOnlyList<InterestTopicDto> InterestTopics,
System.Guid? CountryId,
System.Guid? CountryCodeId,
string? AvatarUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ public sealed record UpdateMyProfileCommand(
string OrganizationName,
string LocalePreference,
KnowledgeLevel KnowledgeLevel,
IReadOnlyList<string> Interests,
string? AvatarUrl,
System.Guid? CountryId,
System.Guid? CountryCodeId) : IRequest<Response<UserProfileDto>>;
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using CCE.Application.Common;
using CCE.Application.Common.Interfaces;
using CCE.Application.Identity.Public.Dtos;
using CCE.Application.InterestManagement.Dtos;
using CCE.Application.Messages;
using MediatR;

Expand Down Expand Up @@ -30,7 +31,6 @@ public async Task<Response<UserProfileDto>> Handle(UpdateMyProfileCommand reques
user.UpdateProfile(request.FirstName, request.LastName, request.JobTitle, request.OrganizationName);
user.SetLocalePreference(request.LocalePreference);
user.SetKnowledgeLevel(request.KnowledgeLevel);
user.UpdateInterests(request.Interests);
user.SetAvatarUrl(request.AvatarUrl);

if (request.CountryId is null)
Expand All @@ -47,6 +47,15 @@ public async Task<Response<UserProfileDto>> Handle(UpdateMyProfileCommand reques
// ICceDbContext as unit of work
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

var interestTopics = user.UserInterestTopics
.Select(uit => new InterestTopicDto(
uit.InterestTopic.Id,
uit.InterestTopic.NameAr,
uit.InterestTopic.NameEn,
uit.InterestTopic.Category,
uit.InterestTopic.IsActive))
.ToList();

return _msg.Ok(new UserProfileDto(
user.Id,
user.Email,
Expand All @@ -58,7 +67,7 @@ public async Task<Response<UserProfileDto>> Handle(UpdateMyProfileCommand reques
user.PhoneNumber,
user.LocalePreference,
user.KnowledgeLevel,
user.Interests,
interestTopics,
user.CountryId,
user.CountryCodeId,
user.AvatarUrl), "PROFILE_UPDATED");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ public UpdateMyProfileCommandValidator()
.Must(l => l == "ar" || l == "en")
.WithMessage("LocalePreference must be 'ar' or 'en'.");

RuleFor(x => x.Interests).NotNull();

RuleFor(x => x.AvatarUrl)
.Must(url => url is null || url.StartsWith("https://", System.StringComparison.OrdinalIgnoreCase))
.WithMessage("AvatarUrl must be null or start with 'https://'.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ public sealed record UpdateMyProfileRequest(
string OrganizationName,
string LocalePreference,
Domain.Identity.KnowledgeLevel KnowledgeLevel,
IReadOnlyList<string>? Interests,
string? AvatarUrl,
System.Guid? CountryId,
System.Guid? CountryCodeId);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using CCE.Application.Common;
using MediatR;

namespace CCE.Application.Identity.Public.Commands.UserInterest;

public sealed record UpsertUserInterestCommand(
System.Guid UserId,
IReadOnlyList<System.Guid>? CarbonAreaIds,
System.Guid? KnowledgeAssessmentId,
System.Guid? JobSectorId,
System.Guid? TargetCountryId) : IRequest<Response<UpsertUserInterestResult>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using CCE.Application.Common;
using CCE.Application.Common.Interfaces;
using CCE.Application.InterestManagement.Dtos;
using CCE.Application.Messages;
using CCE.Domain.Identity;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace CCE.Application.Identity.Public.Commands.UserInterest;

public sealed class UpsertUserInterestCommandHandler
: IRequestHandler<UpsertUserInterestCommand, Response<UpsertUserInterestResult>>
{
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<Response<UpsertUserInterestResult>> Handle(
UpsertUserInterestCommand request,
CancellationToken cancellationToken)
{
var user = await _service.FindAsync(request.UserId, cancellationToken).ConfigureAwait(false);
if (user is null)
return _msg.UserNotFound<UpsertUserInterestResult>();

var errors = new List<FieldError>();

// Validate interest topic IDs exist with correct category
var validTopics = await _db.InterestTopics
.Where(t => t.IsActive)
.Select(t => new { t.Id, t.Category })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);

var validByCategory = validTopics
.GroupBy(t => t.Category)
.ToDictionary(g => g.Key, g => g.Select(t => t.Id).ToHashSet());

if (request.CarbonAreaIds?.Count > 0)
{
var validCarbon = validByCategory.GetValueOrDefault("carbon_area") ?? [];
var invalid = request.CarbonAreaIds.Where(id => !validCarbon.Contains(id)).ToList();
if (invalid.Count > 0)
errors.Add(_msg.Field("carbonAreaIds", "INTEREST_TOPIC_NOT_FOUND"));
}

if (request.KnowledgeAssessmentId.HasValue)
{
var validKa = validByCategory.GetValueOrDefault("knowledge_assessment") ?? [];
if (!validKa.Contains(request.KnowledgeAssessmentId.Value))
errors.Add(_msg.Field("knowledgeAssessmentId", "INTEREST_TOPIC_NOT_FOUND"));
}

if (request.JobSectorId.HasValue)
{
var validJs = validByCategory.GetValueOrDefault("job_sector") ?? [];
if (!validJs.Contains(request.JobSectorId.Value))
errors.Add(_msg.Field("jobSectorId", "INTEREST_TOPIC_NOT_FOUND"));
}

if (request.TargetCountryId.HasValue)
{
var countryExists = await _db.Countries
.AnyAsync(c => c.Id == request.TargetCountryId.Value, cancellationToken)
.ConfigureAwait(false);
if (!countryExists)
errors.Add(_msg.Field("targetCountryId", "COUNTRY_NOT_FOUND"));
}

if (errors.Count > 0)
return _msg.ValidationError<UpsertUserInterestResult>("VALIDATION_ERROR", errors);

// Load category mapping for all interest topics (for filtering by category)
var topicCategoryMap = validTopics
.ToDictionary(t => t.Id, t => t.Category);

// carbon_area — multiple select
UpsertCategory(user, request.CarbonAreaIds, "carbon_area", topicCategoryMap);

// knowledge_assessment — single select
UpsertCategory(user, request.KnowledgeAssessmentId is not null ? [request.KnowledgeAssessmentId.Value] : null, "knowledge_assessment", topicCategoryMap);

// job_sector — single select
UpsertCategory(user, request.JobSectorId is not null ? [request.JobSectorId.Value] : null, "job_sector", topicCategoryMap);

// target country — single select
if (request.TargetCountryId.HasValue)
user.AssignCountry(request.TargetCountryId.Value);
else
user.ClearCountry();

_service.Update(user);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

var currentTopics = await _db.InterestTopics
.Where(t => t.IsActive)
.ToListAsync(cancellationToken);

var carbonAreaTopics = currentTopics
.Where(t => t.Category == "carbon_area" && user.UserInterestTopics.Any(uit => uit.InterestTopicId == t.Id))
.Select(t => new InterestTopicDto(t.Id, t.NameAr, t.NameEn, t.Category, t.IsActive))
.ToList();

var knowledgeAssessmentTopic = currentTopics
.FirstOrDefault(t => t.Category == "knowledge_assessment" && user.UserInterestTopics.Any(uit => uit.InterestTopicId == t.Id));

var jobSectorTopic = currentTopics
.FirstOrDefault(t => t.Category == "job_sector" && user.UserInterestTopics.Any(uit => uit.InterestTopicId == t.Id));

return _msg.InterestUpserted(new UpsertUserInterestResult(
carbonAreaTopics,
knowledgeAssessmentTopic is not null ? new InterestTopicDto(knowledgeAssessmentTopic.Id, knowledgeAssessmentTopic.NameAr, knowledgeAssessmentTopic.NameEn, knowledgeAssessmentTopic.Category, knowledgeAssessmentTopic.IsActive) : null,
jobSectorTopic is not null ? new InterestTopicDto(jobSectorTopic.Id, jobSectorTopic.NameAr, jobSectorTopic.NameEn, jobSectorTopic.Category, jobSectorTopic.IsActive) : null,
user.CountryId));
}

private static void UpsertCategory(
User user,
IReadOnlyList<System.Guid>? newIds,
string category,
Dictionary<System.Guid, string> topicCategoryMap)
{
var newSet = newIds?.Distinct().ToHashSet() ?? [];

var toRemove = user.UserInterestTopics
.Where(uit =>
{
var cat = topicCategoryMap.GetValueOrDefault(uit.InterestTopicId);
return cat == category && !newSet.Contains(uit.InterestTopicId);
})
.ToList();

var existingInCategory = user.UserInterestTopics
.Where(uit =>
{
var cat = topicCategoryMap.GetValueOrDefault(uit.InterestTopicId);
return cat == category;
})
.Select(uit => uit.InterestTopicId)
.ToHashSet();

var toAddIds = newSet
.Where(id => !existingInCategory.Contains(id))
.ToList();

foreach (var remove in toRemove)
user.UserInterestTopics.Remove(remove);
foreach (var id in toAddIds)
user.UserInterestTopics.Add(new UserInterestTopic
{
UserId = user.Id,
InterestTopicId = id
});
}
}
Loading