Skip to content
Open
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
24 changes: 24 additions & 0 deletions backend/src/CCE.Api.Common/Localization/Resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -335,3 +335,27 @@ 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"

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

INTEREST_TOPIC_CREATED:
ar: "تم إنشاء موضوع الاهتمام بنجاح"
en: "Interest topic created successfully"

INTEREST_TOPIC_UPDATED:
ar: "تم تحديث موضوع الاهتمام بنجاح"
en: "Interest topic updated successfully"

INTEREST_TOPIC_DELETED:
ar: "تم حذف موضوع الاهتمام بنجاح"
en: "Interest topic deleted successfully"
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using CCE.Api.Common.Extensions;
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");

return app;
}
}
36 changes: 36 additions & 0 deletions backend/src/CCE.Api.External/Endpoints/UserInterestEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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.InterestTopicIds ?? System.Array.Empty<System.Guid>()), ct).ConfigureAwait(false);
return result.ToHttpResult();
})
.WithName("UpsertUserInterest");

return app;
}
}

public sealed record UpsertUserInterestRequest(IReadOnlyList<System.Guid> InterestTopicIds);
2 changes: 2 additions & 0 deletions backend/src/CCE.Api.External/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@
app.MapAssistantEndpoints();
app.MapKapsarcEndpoints();
app.MapSurveysEndpoints();
app.MapUserInterestEndpoints();
app.MapInterestTopicPublicEndpoints();

app.MapGet("/health", async (IMediator mediator) =>
{
Expand Down
78 changes: 78 additions & 0 deletions backend/src/CCE.Api.Internal/Endpoints/InterestTopicEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using CCE.Api.Common.Extensions;
using CCE.Application.InterestManagement.Commands.CreateInterestTopic;
using CCE.Application.InterestManagement.Commands.DeleteInterestTopic;
using CCE.Application.InterestManagement.Commands.UpdateInterestTopic;
using CCE.Application.InterestManagement.Queries.GetInterestTopicById;
using CCE.Application.InterestManagement.Queries.ListInterestTopics;
using CCE.Domain;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

namespace CCE.Api.Internal.Endpoints;

public static class InterestTopicEndpoints
{
public static IEndpointRouteBuilder MapInterestTopicEndpoints(this IEndpointRouteBuilder app)
{
var topics = app.MapGroup("/api/admin/interest-topics").WithTags("Interest Topics");

topics.MapGet("", async (
IMediator mediator, CancellationToken cancellationToken) =>
{
var result = await mediator.Send(new ListInterestTopicsQuery(), cancellationToken).ConfigureAwait(false);
return result.ToHttpResult();
})
.RequireAuthorization(Permissions.InterestTopic_Manage)
.WithName("ListInterestTopics");

topics.MapGet("/{id:guid}", async (
System.Guid id,
IMediator mediator, CancellationToken cancellationToken) =>
{
var result = await mediator.Send(new GetInterestTopicByIdQuery(id), cancellationToken).ConfigureAwait(false);
return result.ToHttpResult();
})
.RequireAuthorization(Permissions.InterestTopic_Manage)
.WithName("GetInterestTopicById");

topics.MapPost("", async (
CreateInterestTopicRequest body,
IMediator mediator, CancellationToken cancellationToken) =>
{
var result = await mediator.Send(
new CreateInterestTopicCommand(body.NameAr, body.NameEn), cancellationToken).ConfigureAwait(false);
return result.ToHttpResult();
})
.RequireAuthorization(Permissions.InterestTopic_Manage)
.WithName("CreateInterestTopic");

topics.MapPut("/{id:guid}", async (
System.Guid id,
UpdateInterestTopicRequest body,
IMediator mediator, CancellationToken cancellationToken) =>
{
var result = await mediator.Send(
new UpdateInterestTopicCommand(id, body.NameAr, body.NameEn), cancellationToken).ConfigureAwait(false);
return result.ToHttpResult();
})
.RequireAuthorization(Permissions.InterestTopic_Manage)
.WithName("UpdateInterestTopic");

topics.MapDelete("/{id:guid}", async (
System.Guid id,
IMediator mediator, CancellationToken cancellationToken) =>
{
var result = await mediator.Send(new DeleteInterestTopicCommand(id), cancellationToken).ConfigureAwait(false);
return result.ToHttpResult();
})
.RequireAuthorization(Permissions.InterestTopic_Manage)
.WithName("DeleteInterestTopic");

return app;
}
}

public sealed record CreateInterestTopicRequest(string NameAr, string NameEn);
public sealed record UpdateInterestTopicRequest(string NameAr, string NameEn);
1 change: 1 addition & 0 deletions backend/src/CCE.Api.Internal/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
app.MapTopicEndpoints();
app.MapCommunityModerationEndpoints();
app.MapNotificationTemplateEndpoints();
app.MapInterestTopicEndpoints();
app.MapReportEndpoints();
app.MapAuditEndpoints();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public interface ICceDbContext
IQueryable<CityScenario> CityScenarios { get; }
IQueryable<CityTechnology> CityTechnologies { get; }
IQueryable<CityScenarioResult> CityScenarioResults { get; }
IQueryable<InterestTopic> InterestTopics { get; }

Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
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,
string? AvatarUrl,
IReadOnlyList<string> Roles,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,5 @@ public sealed record UpdateMyProfileCommand(
System.Guid UserId,
string LocalePreference,
KnowledgeLevel KnowledgeLevel,
IReadOnlyList<string> Interests,
string? AvatarUrl,
System.Guid? CountryId) : 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 @@ -29,7 +30,6 @@ public async Task<Response<UserProfileDto>> Handle(UpdateMyProfileCommand reques

user.SetLocalePreference(request.LocalePreference);
user.SetKnowledgeLevel(request.KnowledgeLevel);
user.UpdateInterests(request.Interests);
user.SetAvatarUrl(request.AvatarUrl);

if (request.CountryId is null)
Expand All @@ -44,13 +44,21 @@ public async Task<Response<UserProfileDto>> Handle(UpdateMyProfileCommand reques
_service.Update(user);
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.IsActive))
.ToList();

return _msg.Ok(new UserProfileDto(
user.Id,
user.Email,
user.UserName,
user.LocalePreference,
user.KnowledgeLevel,
user.Interests,
interestTopics,
user.CountryId,
user.AvatarUrl), "PROFILE_UPDATED");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,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 @@ -3,6 +3,5 @@ namespace CCE.Application.Identity.Public.Commands.UpdateMyProfile;
public sealed record UpdateMyProfileRequest(
string LocalePreference,
Domain.Identity.KnowledgeLevel KnowledgeLevel,
IReadOnlyList<string>? Interests,
string? AvatarUrl,
System.Guid? CountryId);
Original file line number Diff line number Diff line change
@@ -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<System.Guid> InterestTopicIds) : IRequest<Response<UpsertUserInterestResult>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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 newIds = (request.InterestTopicIds ?? System.Array.Empty<System.Guid>())
.Distinct()
.ToHashSet();

var oldIds = user.UserInterestTopics
.Select(uit => uit.InterestTopicId)
.ToHashSet();

var toRemove = user.UserInterestTopics
.Where(uit => !newIds.Contains(uit.InterestTopicId))
.ToList();

var toAddIds = newIds
.Where(id => !oldIds.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
});

if (toRemove.Count > 0 || toAddIds.Count > 0)
{
_service.Update(user);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}

var currentTopicIds = user.UserInterestTopics
.Select(uit => uit.InterestTopicId)
.ToHashSet();
var currentTopics = await _db.InterestTopics
.Where(t => currentTopicIds.Contains(t.Id))
.Select(t => new InterestTopicDto(t.Id, t.NameAr, t.NameEn, t.IsActive))
.ToListAsync(cancellationToken);

return _msg.InterestUpserted(new UpsertUserInterestResult(
currentTopics,
toAddIds,
toRemove.Select(r => r.InterestTopicId).ToList()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using CCE.Application.InterestManagement.Dtos;

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

public sealed record UpsertUserInterestResult(
IReadOnlyList<InterestTopicDto> InterestTopics,
IReadOnlyList<System.Guid> Added,
IReadOnlyList<System.Guid> Removed);
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.Public.Dtos;
Expand All @@ -8,6 +9,6 @@ public sealed record UserProfileDto(
string? UserName,
string LocalePreference,
KnowledgeLevel KnowledgeLevel,
IReadOnlyList<string> Interests,
IReadOnlyList<InterestTopicDto> InterestTopics,
System.Guid? CountryId,
string? AvatarUrl);
Loading