Skip to content
Closed
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
16 changes: 16 additions & 0 deletions backend/src/CCE.Api.Common/Localization/Resources.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,14 @@ NEWS_NOT_FOUND:
ar: "الخبر غير موجود"
en: "News not found"

NEWS_FOLLOW_NOT_FOUND:
ar: "أنت لا تتابع الأخبار"
en: "You are not following news"

NEWS_FOLLOW_FAILED:
ar: "حدث خطأ أثناء محاولة متابعة الخبر. يرجى المحاولة مرة أخرى لاحقاً."
en: "An error occurred while trying to follow news. Please try again later."

EVENT_NOT_FOUND:
ar: "الفعالية غير موجودة"
en: "Event not found"
Expand Down Expand Up @@ -429,6 +437,14 @@ ASSET_UPLOADED:
ar: "تم رفع الملف بنجاح"
en: "Asset uploaded successfully"

NEWS_FOLLOWED:
ar: "تم متابعة الأخبار بنجاح"
en: "News followed successfully"

NEWS_UNFOLLOWED:
ar: "تم إلغاء متابعة الأخبار بنجاح"
en: "News unfollowed successfully"

RESOURCE_UPDATED:
ar: "تم تحديث المصدر بنجاح"
en: "Resource updated successfully"
Expand Down
10 changes: 10 additions & 0 deletions backend/src/CCE.Api.External/Endpoints/NewsPublicEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using CCE.Api.Common.Extensions;
using CCE.Application.Content.Commands.ToggleNewsFollow;
using CCE.Application.Content.Public.Queries.GetPublicNewsById;
using CCE.Application.Content.Public.Queries.ListPublicNews;
using MediatR;
Expand Down Expand Up @@ -41,6 +42,15 @@ public static IEndpointRouteBuilder MapNewsPublicEndpoints(this IEndpointRouteBu
.AllowAnonymous()
.WithName("GetPublicNewsById");

news.MapPatch("/follow", async (
IMediator mediator, CancellationToken cancellationToken) =>
{
var response = await mediator.Send(new ToggleNewsFollowCommand(), cancellationToken).ConfigureAwait(false);
return response.ToHttpResult();
})
.RequireAuthorization()
.WithName("ToggleNewsFollow");

return app;
}
}
2 changes: 1 addition & 1 deletion backend/src/CCE.Api.External/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
},
"Messaging": {
"Transport": "InMemory",
"UseAsyncDispatcher": true,
"UseAsyncDispatcher": false,
"FallbackToInMemoryIfUnavailable": true
},
"LocalAuth": {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/CCE.Api.Internal/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
},
"Messaging": {
"Transport": "InMemory",
"UseAsyncDispatcher": true,
"UseAsyncDispatcher": false,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignore the appsetting.json

"FallbackToInMemoryIfUnavailable": true
},
"LocalAuth": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ public interface ICceDbContext
IQueryable<DomainCountry.CountryProfile> CountryProfiles { get; }
IQueryable<DomainCountry.CountryKapsarcSnapshot> CountryKapsarcSnapshots { get; }
IQueryable<News> News { get; }
IQueryable<NewsFollow> NewsFollows { get; }
IQueryable<NewsFollowLog> NewsFollowLogs { get; }
IQueryable<Event> Events { get; }
IQueryable<Tag> Tags { get; }
IQueryable<Page> Pages { get; }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using CCE.Application.Common;
using MediatR;

namespace CCE.Application.Content.Commands.ToggleNewsFollow;

public sealed record ToggleNewsFollowCommand : IRequest<Response<VoidData>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using CCE.Application.Common;
using CCE.Application.Common.Interfaces;
using CCE.Application.Messages;
using CCE.Application.Notifications;
using CCE.Domain.Common;
using CCE.Domain.Content;
using CCE.Domain.Notifications;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace CCE.Application.Content.Commands.ToggleNewsFollow;

internal sealed class ToggleNewsFollowCommandHandler(
IRepository<NewsFollow, System.Guid> _repo,
IUserNotificationSettingsRepository _settingsRepo,
ICceDbContext _db,
ICurrentUserAccessor _currentUser,
ISystemClock _clock,
MessageFactory _msg)
: IRequestHandler<ToggleNewsFollowCommand, Response<VoidData>>
{
public async Task<Response<VoidData>> Handle(ToggleNewsFollowCommand request, CancellationToken ct)
{
var userId = _currentUser.GetUserId();
if (userId is null)
return _msg.Unauthorized<VoidData>("NOT_AUTHENTICATED");

var existing = await _db.NewsFollows
.FirstOrDefaultAsync(f => f.UserId == userId.Value, ct)
.ConfigureAwait(false);

if (existing is not null)
_db.Attach(existing);

if (existing is null)
{
var follow = NewsFollow.Follow(userId.Value, userId.Value, _clock);
await _repo.AddAsync(follow, ct).ConfigureAwait(false);

var settings = await _settingsRepo.GetAsync(
userId.Value, NotificationChannel.InApp, "NEWS_PUBLISHED", ct)
.ConfigureAwait(false);

if (settings is null)
{
var ns = UserNotificationSettings.Create(
userId.Value, NotificationChannel.InApp, true, "NEWS_PUBLISHED");
await _settingsRepo.AddAsync(ns, ct).ConfigureAwait(false);
}
else if (!settings.IsEnabled)
{
settings.Update(true);
}

await _db.SaveChangesAsync(ct).ConfigureAwait(false);
return _msg.Ok("NEWS_FOLLOWED");
}

if (existing.Status == FollowStatus.Followed)
{
existing.Unfollow(userId.Value, _clock);
await _db.SaveChangesAsync(ct).ConfigureAwait(false);
return _msg.Ok("NEWS_UNFOLLOWED");
}

existing.ReFollow(userId.Value, _clock);
await _db.SaveChangesAsync(ct).ConfigureAwait(false);
return _msg.Ok("NEWS_FOLLOWED");
}
}
4 changes: 4 additions & 0 deletions backend/src/CCE.Application/Errors/ApplicationErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ public static class Content
public const string CONTENT_PUBLISHED = "CONTENT_PUBLISHED";
public const string CONTENT_ARCHIVED = "CONTENT_ARCHIVED";
public const string ASSET_UPLOADED = "ASSET_UPLOADED";
public const string NEWS_FOLLOWED = "NEWS_FOLLOWED";
public const string NEWS_UNFOLLOWED = "NEWS_UNFOLLOWED";
public const string NEWS_FOLLOW_NOT_FOUND = "NEWS_FOLLOW_NOT_FOUND";
public const string NEWS_FOLLOW_FAILED = "NEWS_FOLLOW_FAILED";
}

public static class Community
Expand Down
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.Dtos;
using CCE.Application.InterestManagement.Dtos;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignore this file

using CCE.Application.Identity.Public;
using CCE.Application.InterestManagement.Dtos;
using CCE.Application.Messages;
Expand Down
4 changes: 4 additions & 0 deletions backend/src/CCE.Application/Messages/MessageFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ public Response<T> CannotFollowSelf<T>() => ValidationError<T>(
public Response<T> CategoryNotFound<T>() => NotFound<T>("CATEGORY_NOT_FOUND");
public Response<T> AssetNotFound<T>() => NotFound<T>("ASSET_NOT_FOUND");
public Response<T> AssetNotClean<T>() => BusinessRule<T>("ASSET_NOT_CLEAN");
public Response<VoidData> NewsFollowed() => Ok("NEWS_FOLLOWED");
public Response<VoidData> NewsUnfollowed() => Ok("NEWS_UNFOLLOWED");
public Response<T> NewsFollowNotFound<T>() => NotFound<T>("NEWS_FOLLOW_NOT_FOUND");
public Response<T> NewsFollowFailed<T>() => BusinessRule<T>("NEWS_FOLLOW_FAILED");

// ─── Convenience shortcuts (Identity / Expert domain) ───

Expand Down
4 changes: 4 additions & 0 deletions backend/src/CCE.Application/Messages/SystemCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public static class SystemCode
public const string ERR001 = "ERR001"; // User not found (also used as ERR001 in appendix — keep)
public const string ERR002 = "ERR002"; // Resource download failure (appendix)
public const string ERR003 = "ERR003"; // Resource share failure (appendix)
public const string ERR005 = "ERR005"; // News follow failure (US012)
public const string ERR004 = "ERR004"; // No verified contact (email/phone)
public const string ERR013 = "ERR013"; // Required fields empty (appendix)

Expand Down Expand Up @@ -99,6 +100,9 @@ public static class SystemCode
public const string ERR091 = "ERR091"; // Node not found
public const string ERR092 = "ERR092"; // Edge not found

// ─── Content Errors (extended) ───
public const string ERR093 = "ERR093"; // News follow not found

// ─── Media Errors ───
public const string ERR110 = "ERR110"; // Media file not found
public const string ERR111 = "ERR111"; // Invalid file type
Expand Down
4 changes: 4 additions & 0 deletions backend/src/CCE.Application/Messages/SystemCodeMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public static class SystemCodeMap
["RESOURCE_DOWNLOAD_FAILED"] = SystemCode.ERR002,
["RESOURCE_UPLOAD_FAILED"] = SystemCode.ERR029,
["RESOURCE_DELETE_FAILED"] = SystemCode.ERR030,
["NEWS_FOLLOW_NOT_FOUND"] = SystemCode.ERR093,
["NEWS_FOLLOW_FAILED"] = SystemCode.ERR005,

// ─── Community Errors ───
["TOPIC_NOT_FOUND"] = SystemCode.ERR060,
Expand Down Expand Up @@ -189,6 +191,8 @@ public static class SystemCodeMap
["RESOURCE_UPDATED"] = SystemCode.CON026,
["RESOURCE_DELETED"] = SystemCode.CON022,
["RESOURCE_PUBLISHED"] = SystemCode.CON028,
["NEWS_FOLLOWED"] = SystemCode.CON033,
["NEWS_UNFOLLOWED"] = SystemCode.CON034,
["RESOURCE_DOWNLOAD_SUCCESS"] = SystemCode.CON001,
["RESOURCE_SHARE_SUCCESS"] = SystemCode.CON002,
["RESOURCE_SHARE_FAILED"] = SystemCode.ERR003,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using CCE.Application.Common.Interfaces;
using CCE.Application.Content;
using CCE.Application.Notifications.Messages;
using CCE.Domain.Common;
using CCE.Domain.Content;
using CCE.Domain.Content.Events;
using CCE.Domain.Notifications;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace CCE.Application.Notifications.Handlers;
Expand All @@ -13,15 +16,21 @@ public sealed class NewsPublishedNotificationHandler
{
private readonly INewsRepository _newsRepo;
private readonly INotificationMessageDispatcher _dispatcher;
private readonly ICceDbContext _db;
private readonly ISystemClock _clock;
private readonly ILogger<NewsPublishedNotificationHandler> _logger;

public NewsPublishedNotificationHandler(
INewsRepository newsRepo,
INotificationMessageDispatcher dispatcher,
ICceDbContext db,
ISystemClock clock,
ILogger<NewsPublishedNotificationHandler> logger)
{
_newsRepo = newsRepo;
_dispatcher = dispatcher;
_db = db;
_clock = clock;
_logger = logger;
}

Expand All @@ -37,12 +46,28 @@ public async Task Handle(NewsPublishedEvent notification, CancellationToken canc
return;
}

await _dispatcher.DispatchAsync(new NotificationMessage(
TemplateCode: "NEWS_PUBLISHED",
RecipientUserId: news.AuthorId,
EventType: NotificationEventType.NewsPublished,
Channels: [NotificationChannel.InApp],
MetaData: new Dictionary<string, string>(),
Locale: "en"), cancellationToken).ConfigureAwait(false);
var followerIds = await _db.NewsFollows
.Where(f => f.Status == FollowStatus.Followed && f.UserId != news.AuthorId)
.Select(f => f.UserId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);

var recipientIds = new HashSet<System.Guid>(followerIds) { news.AuthorId };

foreach (var userId in followerIds)
_db.Add(NewsFollowLog.Log(userId, notification.NewsId, _clock));

await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

foreach (var userId in recipientIds)
{
await _dispatcher.DispatchAsync(new NotificationMessage(
TemplateCode: "NEWS_PUBLISHED",
RecipientUserId: userId,
EventType: NotificationEventType.NewsPublished,
Channels: [NotificationChannel.InApp, NotificationChannel.Email],
MetaData: new Dictionary<string, string>(),
Locale: "en"), cancellationToken).ConfigureAwait(false);
}
}
}
7 changes: 7 additions & 0 deletions backend/src/CCE.Domain/Content/FollowStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace CCE.Domain.Content;

public enum FollowStatus
{
Followed = 0,
Unfollowed = 1,
}
38 changes: 38 additions & 0 deletions backend/src/CCE.Domain/Content/NewsFollow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using CCE.Domain.Common;

namespace CCE.Domain.Content;

public sealed class NewsFollow : AuditableEntity<System.Guid>
{
private NewsFollow(System.Guid id, System.Guid userId) : base(id)
{
UserId = userId;
Status = FollowStatus.Followed;
}

public System.Guid UserId { get; private set; }
public FollowStatus Status { get; private set; }
public System.DateTimeOffset? UnfollowedOn { get; private set; }

public static NewsFollow Follow(System.Guid userId, System.Guid createdBy, ISystemClock clock)
{
if (userId == System.Guid.Empty) throw new DomainException("UserId is required.");
var follow = new NewsFollow(System.Guid.NewGuid(), userId);
follow.MarkAsCreated(createdBy, clock);
return follow;
}

public void ReFollow(System.Guid modifiedBy, ISystemClock clock)
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why you are using refollow insted of follow

Status = FollowStatus.Followed;
UnfollowedOn = null;
MarkAsModified(modifiedBy, clock);
}

public void Unfollow(System.Guid modifiedBy, ISystemClock clock)
{
Status = FollowStatus.Unfollowed;
UnfollowedOn = clock.UtcNow;
MarkAsModified(modifiedBy, clock);
}
}
26 changes: 26 additions & 0 deletions backend/src/CCE.Domain/Content/NewsFollowLog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using CCE.Domain.Common;

namespace CCE.Domain.Content;

public sealed class NewsFollowLog : Entity<System.Guid>
{
private NewsFollowLog(
System.Guid id,
System.Guid userId,
System.Guid newsId,
System.DateTimeOffset timestamp) : base(id)
{
UserId = userId;
NewsId = newsId;
Timestamp = timestamp;
}

public System.Guid UserId { get; private set; }
public System.Guid NewsId { get; private set; }
public System.DateTimeOffset Timestamp { get; private set; }

public static NewsFollowLog Log(System.Guid userId, System.Guid newsId, ISystemClock clock)
{
return new NewsFollowLog(System.Guid.NewGuid(), userId, newsId, clock.UtcNow);
}
}
9 changes: 0 additions & 9 deletions backend/src/CCE.Domain/Identity/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,15 +182,6 @@ public void SetLocalePreference(string locale)

public void SetKnowledgeLevel(KnowledgeLevel level) => KnowledgeLevel = level;

public void UpdateInterests(IEnumerable<System.Guid> interestTopicIds)
{
if (interestTopicIds is null)
throw new DomainException("interestTopicIds collection cannot be null.");
UserInterestTopics.Clear();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for this file

foreach (var id in interestTopicIds.Distinct())
UserInterestTopics.Add(new UserInterestTopic { UserId = Id, InterestTopicId = id });
}

public bool IsDeleted { get; private set; }

public DateTimeOffset? DeletedOn { get; private set; }
Expand Down
Loading