Remove giant ActivityDto ternary and add more documentation in some areas

This commit is contained in:
Slendy 2024-03-25 01:03:53 -05:00
commit c2011837d7
No known key found for this signature in database
GPG key ID: 7288D68361B91428
23 changed files with 760 additions and 168 deletions

View file

@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"dotnet-ef": { "dotnet-ef": {
"version": "7.0.13", "version": "8.0.0",
"commands": [ "commands": [
"dotnet-ef" "dotnet-ef"
] ]

View file

@ -56,7 +56,7 @@ public class ActivityController : ControllerBase
.ToListAsync(); .ToListAsync();
List<int>? friendIds = UserFriendStore.GetUserFriendData(token.UserId)?.FriendIds; List<int>? friendIds = UserFriendStore.GetUserFriendData(token.UserId)?.FriendIds;
friendIds ??= new List<int>(); friendIds ??= [];
// This is how lbp3 does its filtering // This is how lbp3 does its filtering
GameStreamFilter? filter = await this.DeserializeBody<GameStreamFilter>(); GameStreamFilter? filter = await this.DeserializeBody<GameStreamFilter>();
@ -89,7 +89,7 @@ public class ActivityController : ControllerBase
predicate = predicate.Or(dto => dto.TargetSlotCreatorId == token.UserId); predicate = predicate.Or(dto => dto.TargetSlotCreatorId == token.UserId);
} }
List<int> includedUserIds = new(); List<int> includedUserIds = [];
if (!excludeFriends) if (!excludeFriends)
{ {
@ -168,7 +168,7 @@ public class ActivityController : ControllerBase
private static DateTime GetOldestTime private static DateTime GetOldestTime
(IReadOnlyCollection<IGrouping<ActivityGroup, ActivityDto>> groups, DateTime defaultTimestamp) => (IReadOnlyCollection<IGrouping<ActivityGroup, ActivityDto>> groups, DateTime defaultTimestamp) =>
groups.Any() groups.Count != 0
? groups.Min(g => g.MinBy(a => a.Activity.Timestamp)?.Activity.Timestamp ?? defaultTimestamp) ? groups.Min(g => g.MinBy(a => a.Activity.Timestamp)?.Activity.Timestamp ?? defaultTimestamp)
: defaultTimestamp; : defaultTimestamp;
@ -247,7 +247,7 @@ public class ActivityController : ControllerBase
{ {
GameTokenEntity token = this.GetToken(); GameTokenEntity token = this.GetToken();
if (token.GameVersion == GameVersion.LittleBigPlanet1) return this.NotFound(); if (token.GameVersion is GameVersion.LittleBigPlanet1 or GameVersion.LittleBigPlanetPSP) return this.NotFound();
IQueryable<ActivityDto> activityEvents = await this.GetFilters(this.database.Activities.ToActivityDto(true), IQueryable<ActivityDto> activityEvents = await this.GetFilters(this.database.Activities.ToActivityDto(true),
token, token,
@ -305,10 +305,11 @@ public class ActivityController : ControllerBase
{ {
GameTokenEntity token = this.GetToken(); GameTokenEntity token = this.GetToken();
if (token.GameVersion == GameVersion.LittleBigPlanet1) return this.NotFound(); if (token.GameVersion is GameVersion.LittleBigPlanet1 or GameVersion.LittleBigPlanetPSP) return this.NotFound();
if ((SlotHelper.IsTypeInvalid(slotType) || slotId == 0) == (username == null)) return this.BadRequest(); if ((SlotHelper.IsTypeInvalid(slotType) || slotId == 0) == (username == null)) return this.BadRequest();
// User and Level activity will never contain news posts or MM pick events.
IQueryable<ActivityDto> activityQuery = this.database.Activities.ToActivityDto() IQueryable<ActivityDto> activityQuery = this.database.Activities.ToActivityDto()
.Where(a => a.Activity.Type != EventType.NewsPost && a.Activity.Type != EventType.MMPickLevel); .Where(a => a.Activity.Type != EventType.NewsPost && a.Activity.Type != EventType.MMPickLevel);
@ -343,6 +344,8 @@ public class ActivityController : ControllerBase
List<OuterActivityGroup> outerGroups = groups.ToOuterActivityGroups(); List<OuterActivityGroup> outerGroups = groups.ToOuterActivityGroups();
PrintOuterGroups(outerGroups);
long oldestTimestamp = GetOldestTime(groups, times.Start).ToUnixTimeMilliseconds(); long oldestTimestamp = GetOldestTime(groups, times.Start).ToUnixTimeMilliseconds();
await this.CacheEntities(outerGroups); await this.CacheEntities(outerGroups);

View file

@ -1,31 +1,345 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Tests.Helpers;
using LBPUnion.ProjectLighthouse.Types.Activity; using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Activity; using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Entities.Website;
using LBPUnion.ProjectLighthouse.Types.Users;
using Microsoft.EntityFrameworkCore;
using Xunit; using Xunit;
namespace ProjectLighthouse.Tests.GameApiTests.Unit.Activity; namespace ProjectLighthouse.Tests.GameApiTests.Unit.Activity;
[Trait("Category", "Unit")]
public class ActivityGroupingTests public class ActivityGroupingTests
{ {
[Fact] [Fact]
public void ActivityGroupingTest() public void ToOuterActivityGroups_ShouldCreateGroupPerObject_WhenGroupedBy_ObjectThenActor()
{ {
List<ActivityDto> activities = new() List<ActivityEntity> activities = [
new LevelActivityEntity
{ {
new ActivityDto UserId = 1,
SlotId = 1,
Slot = new SlotEntity
{ {
TargetPlaylistId = 1, GameVersion = GameVersion.LittleBigPlanet2,
Activity = new ActivityEntity(),
}, },
}; Timestamp = DateTime.Now,
List<OuterActivityGroup> groups = activities.AsQueryable().ToActivityGroups().ToList().ToOuterActivityGroups(); Type = EventType.PlayLevel,
},
new LevelActivityEntity
{
UserId = 1,
SlotId = 1,
Slot = new SlotEntity
{
GameVersion = GameVersion.LittleBigPlanet2,
},
Timestamp = DateTime.Now,
Type = EventType.ReviewLevel,
},
new LevelActivityEntity
{
UserId = 2,
SlotId = 1,
Slot = new SlotEntity
{
GameVersion = GameVersion.LittleBigPlanet2,
},
Timestamp = DateTime.Now,
Type = EventType.PlayLevel,
},
new UserActivityEntity
{
TargetUserId = 2,
UserId = 1,
Type = EventType.HeartUser,
Timestamp = DateTime.Now,
},
new UserActivityEntity
{
TargetUserId = 2,
UserId = 1,
Type = EventType.CommentOnUser,
Timestamp = DateTime.Now,
},
new UserActivityEntity
{
TargetUserId = 1,
UserId = 2,
Type = EventType.HeartUser,
Timestamp = DateTime.Now,
},
new UserActivityEntity
{
TargetUserId = 1,
UserId = 2,
Type = EventType.CommentOnUser,
Timestamp = DateTime.Now,
},
];
//TODO: fix test
List<OuterActivityGroup> groups = activities.ToActivityDto().AsQueryable().ToActivityGroups().ToList().ToOuterActivityGroups();
Assert.NotNull(groups); Assert.NotNull(groups);
Assert.Single(groups); Assert.Single(groups);
OuterActivityGroup groupEntry = groups.First(); OuterActivityGroup outerGroup = groups.First();
Assert.Equal(ActivityGroupType.Playlist, groupEntry.Key.GroupType); Assert.Equal(ActivityGroupType.Level, outerGroup.Key.GroupType);
Assert.Equal(1, groupEntry.Key.TargetId); Assert.Equal(1, outerGroup.Key.TargetSlotId);
IGrouping<InnerActivityGroup, ActivityDto>? firstGroup = outerGroup.Groups.First();
IGrouping<InnerActivityGroup, ActivityDto>? secondGroup = outerGroup.Groups.Last();
Assert.NotNull(secondGroup);
Assert.Equal(ActivityGroupType.User, secondGroup.Key.Type);
Assert.Equal(1, secondGroup.Key.TargetId); // user group should have the user id
Assert.Equal(1, secondGroup.ToList()[0].TargetSlotId); // events in user group should have t
Assert.Equal(1, secondGroup.ToList()[1].TargetSlotId);
Assert.NotNull(firstGroup);
Assert.Equal(ActivityGroupType.User, firstGroup.Key.Type);
Assert.Equal(2, firstGroup.Key.TargetId);
Assert.Equal(1, firstGroup.ToList()[0].TargetSlotId);
}
[Fact]
public void ToOuterActivityGroups_ShouldCreateGroupPerObject_WhenGroupedBy_ActorThenObject()
{
List<ActivityEntity> activities = [
new LevelActivityEntity
{
UserId = 1,
SlotId = 1,
Slot = new SlotEntity
{
GameVersion = GameVersion.LittleBigPlanet2,
},
Timestamp = DateTime.Now,
Type = EventType.PlayLevel,
},
new LevelActivityEntity
{
UserId = 1,
SlotId = 1,
Slot = new SlotEntity
{
GameVersion = GameVersion.LittleBigPlanet2,
},
Timestamp = DateTime.Now,
Type = EventType.ReviewLevel,
},
new LevelActivityEntity
{
UserId = 2,
SlotId = 1,
Slot = new SlotEntity
{
GameVersion = GameVersion.LittleBigPlanet2,
},
Timestamp = DateTime.Now,
Type = EventType.PlayLevel,
},
new UserActivityEntity
{
TargetUserId = 2,
UserId = 1,
Type = EventType.HeartUser,
Timestamp = DateTime.Now,
},
new UserActivityEntity
{
TargetUserId = 2,
UserId = 1,
Type = EventType.CommentOnUser,
Timestamp = DateTime.Now,
},
new UserActivityEntity
{
TargetUserId = 1,
UserId = 2,
Type = EventType.HeartUser,
Timestamp = DateTime.Now,
},
new UserActivityEntity
{
TargetUserId = 1,
UserId = 2,
Type = EventType.CommentOnUser,
Timestamp = DateTime.Now,
},
];
List<OuterActivityGroup> groups = activities.ToActivityDto()
.AsQueryable()
.ToActivityGroups(true)
.ToList()
.ToOuterActivityGroups(true);
//TODO: fix test
Assert.Multiple(() =>
{
Assert.NotNull(groups);
Assert.Equal(2, groups.Count);
OuterActivityGroup firstUserGroup = groups.FirstOrDefault(g => g.Key.UserId == 1);
OuterActivityGroup secondUserGroup = groups.FirstOrDefault(g => g.Key.UserId == 2);
Assert.NotNull(firstUserGroup.Groups);
Assert.NotNull(secondUserGroup.Groups);
Assert.Equal(ActivityGroupType.User, firstUserGroup.Key.GroupType);
Assert.Equal(ActivityGroupType.User, secondUserGroup.Key.GroupType);
Assert.Single(firstUserGroup.Groups);
Assert.Single(secondUserGroup.Groups);
Assert.Equal(2, firstUserGroup.Groups.ToList()[0].Count());
Assert.Single(secondUserGroup.Groups.ToList()[0]);
// Assert.Equal(ActivityGroupType.Level, outerGroup.Key.GroupType);
// Assert.Equal(1, outerGroup.Key.TargetSlotId);
//
// IGrouping<InnerActivityGroup, ActivityDto>? firstGroup = outerGroup.Groups.First();
// IGrouping<InnerActivityGroup, ActivityDto>? secondGroup = outerGroup.Groups.Last();
//
// Assert.NotNull(secondGroup);
// Assert.Equal(ActivityGroupType.User, secondGroup.Key.Type);
// Assert.Equal(1, secondGroup.Key.TargetId); // user group should have the user id
// Assert.Equal(1, secondGroup.ToList()[0].TargetSlotId); // events in user group should have t
// Assert.Equal(1, secondGroup.ToList()[1].TargetSlotId);
//
// Assert.NotNull(firstGroup);
// Assert.Equal(ActivityGroupType.User, firstGroup.Key.Type);
// Assert.Equal(2, firstGroup.Key.TargetId);
// Assert.Equal(1, firstGroup.ToList()[0].TargetSlotId);
});
}
[Fact]
public async Task ToActivityDtoTest()
{
DatabaseContext db = await MockHelper.GetTestDatabase();
db.Slots.Add(new SlotEntity
{
SlotId = 1,
CreatorId = 1,
GameVersion = GameVersion.LittleBigPlanet2,
});
db.Slots.Add(new SlotEntity
{
SlotId = 2,
CreatorId = 1,
GameVersion = GameVersion.LittleBigPlanet2,
TeamPickTime = 1,
});
db.Reviews.Add(new ReviewEntity
{
Timestamp = DateTime.Now.ToUnixTimeMilliseconds(),
SlotId = 1,
ReviewerId = 1,
ReviewId = 1,
});
db.Comments.Add(new CommentEntity
{
TargetSlotId = 1,
PosterUserId = 1,
Message = "comment on level test",
CommentId = 1,
});
db.Comments.Add(new CommentEntity
{
TargetUserId = 1,
PosterUserId = 1,
Message = "comment on user test",
CommentId = 2,
});
db.WebsiteAnnouncements.Add(new WebsiteAnnouncementEntity
{
PublisherId = 1,
AnnouncementId = 1,
});
db.Playlists.Add(new PlaylistEntity
{
PlaylistId = 1,
CreatorId = 1,
});
db.Activities.Add(new LevelActivityEntity
{
Timestamp = DateTime.Now,
SlotId = 1,
Type = EventType.PlayLevel,
UserId = 1,
});
db.Activities.Add(new ReviewActivityEntity
{
Timestamp = DateTime.Now,
SlotId = 1,
Type = EventType.ReviewLevel,
ReviewId = 1,
UserId = 1,
});
db.Activities.Add(new UserCommentActivityEntity
{
Timestamp = DateTime.Now,
Type = EventType.CommentOnUser,
UserId = 1,
TargetUserId = 1,
CommentId = 2,
});
db.Activities.Add(new LevelCommentActivityEntity
{
Timestamp = DateTime.Now,
Type = EventType.CommentOnLevel,
UserId = 1,
SlotId = 1,
CommentId = 1,
});
db.Activities.Add(new NewsActivityEntity
{
Type = EventType.NewsPost,
NewsId = 1,
UserId = 1,
});
db.Activities.Add(new PlaylistActivityEntity
{
Type = EventType.CreatePlaylist,
PlaylistId = 1,
UserId = 1,
});
db.Activities.Add(new LevelActivityEntity
{
Type = EventType.MMPickLevel,
SlotId = 2,
UserId = 1,
});
await db.SaveChangesAsync();
var sql = db.Activities.ToActivityDto().ToQueryString();
List<ActivityDto> resultDto = await db.Activities.ToActivityDto(includeSlotCreator: true, includeTeamPick: true).ToListAsync();
Assert.Equal(2, resultDto.FirstOrDefault(a => a.Activity.Type == EventType.MMPickLevel)?.TargetTeamPickId);
Assert.Equal(2, resultDto.FirstOrDefault(a => a.Activity.Type == EventType.MMPickLevel)?.TargetSlotId);
Assert.Equal(1, resultDto.FirstOrDefault(a => a.Activity.Type == EventType.MMPickLevel)?.TargetSlotCreatorId);
Assert.Equal(1, resultDto.FirstOrDefault(a => a.Activity.Type == EventType.CreatePlaylist)?.TargetPlaylistId);
Assert.Equal(1, resultDto.FirstOrDefault(a => a.Activity.Type == EventType.NewsPost)?.TargetNewsId);
Assert.Equal(1, resultDto.FirstOrDefault(a => a.Activity.Type == EventType.CommentOnUser)?.TargetUserId);
Assert.Null(resultDto.FirstOrDefault(a => a.Activity.Type == EventType.CommentOnLevel)?.TargetTeamPickId);
Assert.Equal(1, resultDto.FirstOrDefault(a => a.Activity.Type == EventType.CommentOnLevel)?.TargetSlotId);
Assert.Equal(1, resultDto.FirstOrDefault(a => a.Activity.Type == EventType.CommentOnLevel)?.TargetSlotCreatorId);
Assert.Equal(1, resultDto.FirstOrDefault(a => a.Activity.Type == EventType.ReviewLevel)?.TargetSlotId);
Assert.Equal(1, resultDto.FirstOrDefault(a => a.Activity.Type == EventType.ReviewLevel)?.TargetSlotCreatorId);
Assert.Equal(1, resultDto.FirstOrDefault(a => a.Activity.Type == EventType.PlayLevel)?.TargetSlotId);
Assert.Equal(1, resultDto.FirstOrDefault(a => a.Activity.Type == EventType.PlayLevel)?.TargetSlotCreatorId);
} }
} }

View file

@ -93,7 +93,7 @@ public class ActivityInterceptor : SaveChangesInterceptor
{ {
if (eventData.Context is not DatabaseContext context) return; if (eventData.Context is not DatabaseContext context) return;
HashSet<CustomTrackedEntity> entities = new(); HashSet<CustomTrackedEntity> entities = [];
List<EntityEntry> entries = context.ChangeTracker.Entries().ToList(); List<EntityEntry> entries = context.ChangeTracker.Entries().ToList();

View file

@ -109,13 +109,15 @@ public partial class DatabaseContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<LevelActivityEntity>().UseTphMappingStrategy(); modelBuilder.Entity<LevelActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<PhotoActivityEntity>().UseTphMappingStrategy(); modelBuilder.Entity<UserPhotoActivity>().UseTphMappingStrategy();
modelBuilder.Entity<LevelPhotoActivity>().UseTphMappingStrategy();
modelBuilder.Entity<PlaylistActivityEntity>().UseTphMappingStrategy(); modelBuilder.Entity<PlaylistActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<PlaylistWithSlotActivityEntity>().UseTphMappingStrategy(); modelBuilder.Entity<PlaylistWithSlotActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<ScoreActivityEntity>().UseTphMappingStrategy(); modelBuilder.Entity<ScoreActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<UserActivityEntity>().UseTphMappingStrategy(); modelBuilder.Entity<UserActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<NewsActivityEntity>().UseTphMappingStrategy(); modelBuilder.Entity<NewsActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<CommentActivityEntity>().UseTphMappingStrategy(); modelBuilder.Entity<LevelCommentActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<UserCommentActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<UserActivityEntity>().UseTphMappingStrategy(); modelBuilder.Entity<UserActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<ReviewActivityEntity>().UseTphMappingStrategy(); modelBuilder.Entity<ReviewActivityEntity>().UseTphMappingStrategy();
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);

View file

@ -2,7 +2,6 @@
using System.Linq; using System.Linq;
using LBPUnion.ProjectLighthouse.Types.Activity; using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Activity; using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
namespace LBPUnion.ProjectLighthouse.Extensions; namespace LBPUnion.ProjectLighthouse.Extensions;
@ -10,7 +9,7 @@ public static class ActivityQueryExtensions
{ {
public static List<int> GetIds(this IReadOnlyCollection<OuterActivityGroup> groups, ActivityGroupType type) public static List<int> GetIds(this IReadOnlyCollection<OuterActivityGroup> groups, ActivityGroupType type)
{ {
List<int> ids = new(); List<int> ids = [];
// Add outer group ids // Add outer group ids
ids.AddRange(groups.Where(g => g.Key.GroupType == type) ids.AddRange(groups.Where(g => g.Key.GroupType == type)
.Where(g => g.Key.TargetId != 0) .Where(g => g.Key.TargetId != 0)
@ -29,6 +28,12 @@ public static class ActivityQueryExtensions
return ids.Distinct().ToList(); return ids.Distinct().ToList();
} }
/// <summary>
/// Turns a list of <see cref="ActivityDto"/> into a group based on its timestamp
/// </summary>
/// <param name="activityQuery">An <see cref="IQueryable{ActivityDto}"/> to group</param>
/// <param name="groupByActor">Whether or not the groups should be created based on the initiator of the event or the target of the event</param>
/// <returns>The transformed query containing groups of <see cref="ActivityDto"/></returns>
public static IQueryable<IGrouping<ActivityGroup, ActivityDto>> ToActivityGroups public static IQueryable<IGrouping<ActivityGroup, ActivityDto>> ToActivityGroups
(this IQueryable<ActivityDto> activityQuery, bool groupByActor = false) => (this IQueryable<ActivityDto> activityQuery, bool groupByActor = false) =>
groupByActor groupByActor
@ -59,74 +64,65 @@ public static class ActivityQueryExtensions
Groups = g.OrderByDescending(a => a.Activity.Timestamp) Groups = g.OrderByDescending(a => a.Activity.Timestamp)
.GroupBy(gr => new InnerActivityGroup .GroupBy(gr => new InnerActivityGroup
{ {
Type = groupByActor ? gr.GroupType : gr.GroupType != ActivityGroupType.News ? ActivityGroupType.User : ActivityGroupType.News, Type = groupByActor
? gr.GroupType
: gr.GroupType != ActivityGroupType.News
? ActivityGroupType.User
: ActivityGroupType.News,
UserId = gr.Activity.UserId, UserId = gr.Activity.UserId,
TargetId = groupByActor ? gr.TargetId : gr.GroupType != ActivityGroupType.News ? gr.Activity.UserId : gr.TargetNewsId ?? 0, TargetId = groupByActor
? gr.TargetId
: gr.GroupType != ActivityGroupType.News
? gr.Activity.UserId
: gr.TargetNewsId ?? 0,
}) })
.ToList(), .ToList(),
}) })
.ToList(); .ToList();
// WARNING - To the next person who tries to improve this code: As of writing this, it's not possible /// <summary>
// to build a pattern matching switch statement with expression trees. so the only other option /// Converts an <see cref="IQueryable"/>&lt;<see cref="ActivityEntity"/>&gt; into an <see cref="IQueryable"/>&lt;<see cref="ActivityDto"/>&gt; for grouping.
// is to basically rewrite this nested ternary mess with expression trees which isn't much better /// </summary>
// The resulting SQL generated by EntityFramework uses a CASE statement which is probably fine /// <param name="activityQuery">The activity query to be converted.</param>
// TOTAL HOURS WASTED: 3 /// <param name="includeSlotCreator">Whether or not the <see cref="ActivityDto.TargetSlotCreatorId"/> field should be included.</param>
/// <param name="includeTeamPick">Whether or not the <see cref="ActivityDto.TargetTeamPickId"/> field should be included.</param>
/// <returns>The converted <see cref="IQueryable"/>&lt;<see cref="ActivityDto"/>&gt;</returns>
public static IQueryable<ActivityDto> ToActivityDto public static IQueryable<ActivityDto> ToActivityDto
(this IQueryable<ActivityEntity> activityQuery, bool includeSlotCreator = false, bool includeTeamPick = false) (this IQueryable<ActivityEntity> activityQuery, bool includeSlotCreator = false, bool includeTeamPick = false)
{ {
return activityQuery.Select(a => new ActivityDto return activityQuery.Select(a => new ActivityDto
{ {
Activity = a, Activity = a,
TargetSlotId = a is LevelActivityEntity TargetSlotId = (a as LevelActivityEntity).SlotId,
? ((LevelActivityEntity)a).SlotId TargetSlotGameVersion = (a as LevelActivityEntity).Slot.GameVersion,
: a is PhotoActivityEntity && ((PhotoActivityEntity)a).Photo.SlotId != 0 TargetSlotCreatorId = includeSlotCreator ? (a as LevelActivityEntity).Slot.CreatorId : null,
? ((PhotoActivityEntity)a).Photo.SlotId TargetUserId = (a as UserActivityEntity).TargetUserId,
: a is CommentActivityEntity && ((CommentActivityEntity)a).Comment.Type == CommentType.Level TargetNewsId = (a as NewsActivityEntity).NewsId,
? ((CommentActivityEntity)a).Comment.TargetSlotId TargetPlaylistId = (a as PlaylistActivityEntity).PlaylistId,
: a is ScoreActivityEntity TargetTeamPickId =
? ((ScoreActivityEntity)a).Score.SlotId includeTeamPick && a.Type == EventType.MMPickLevel ? (a as LevelActivityEntity).SlotId : null, });
: a is ReviewActivityEntity }
? ((ReviewActivityEntity)a).Review.SlotId
: 0,
TargetSlotGameVersion = a is LevelActivityEntity
? ((LevelActivityEntity)a).Slot.GameVersion
: a is PhotoActivityEntity && ((PhotoActivityEntity)a).Photo.SlotId != 0
? ((PhotoActivityEntity)a).Photo.Slot.GameVersion
: a is CommentActivityEntity && ((CommentActivityEntity)a).Comment.Type == CommentType.Level
? ((CommentActivityEntity)a).Comment.TargetSlot.GameVersion
: a is ScoreActivityEntity
? ((ScoreActivityEntity)a).Score.Slot.GameVersion
: a is ReviewActivityEntity
? ((ReviewActivityEntity)a).Review.Slot.GameVersion
: 0,
TargetSlotCreatorId = includeSlotCreator
? a is LevelActivityEntity
? ((LevelActivityEntity)a).Slot.CreatorId
: a is PhotoActivityEntity && ((PhotoActivityEntity)a).Photo.SlotId != 0
? ((PhotoActivityEntity)a).Photo.Slot!.CreatorId
: a is CommentActivityEntity && ((CommentActivityEntity)a).Comment.Type == CommentType.Level
? ((CommentActivityEntity)a).Comment.TargetSlot.CreatorId
: a is ScoreActivityEntity
? ((ScoreActivityEntity)a).Score.Slot.CreatorId
: a is ReviewActivityEntity
? ((ReviewActivityEntity)a).Review.Slot!.CreatorId
: 0
: 0,
TargetUserId = a is UserActivityEntity /// <summary>
? ((UserActivityEntity)a).TargetUserId /// Converts an IEnumerable&lt;<see cref="ActivityEntity"/>&gt; into an IEnumerable&lt;<see cref="ActivityDto"/>&gt; for grouping.
: a is CommentActivityEntity && ((CommentActivityEntity)a).Comment.Type == CommentType.Profile /// </summary>
? ((CommentActivityEntity)a).Comment.TargetUserId /// <param name="activityEnumerable">The activity query to be converted.</param>
: a is PhotoActivityEntity && ((PhotoActivityEntity)a).Photo.SlotId == 0 /// <param name="includeSlotCreator">Whether or not the <see cref="ActivityDto.TargetSlotCreatorId"/> field should be included.</param>
? ((PhotoActivityEntity)a).Photo.CreatorId /// <param name="includeTeamPick">Whether or not the <see cref="ActivityDto.TargetTeamPickId"/> field should be included.</param>
: 0, /// <returns>The converted IEnumerable&lt;<see cref="ActivityDto"/>&gt;</returns>
TargetPlaylistId = a is PlaylistActivityEntity || a is PlaylistWithSlotActivityEntity public static IEnumerable<ActivityDto> ToActivityDto
? ((PlaylistActivityEntity)a).PlaylistId (this IEnumerable<ActivityEntity> activityEnumerable, bool includeSlotCreator = false, bool includeTeamPick = false)
: 0, {
TargetNewsId = a is NewsActivityEntity ? ((NewsActivityEntity)a).NewsId : 0, return activityEnumerable.Select(a => new ActivityDto
TargetTeamPickId = includeTeamPick {
? a.Type == EventType.MMPickLevel && a is LevelActivityEntity ? ((LevelActivityEntity)a).SlotId : 0 Activity = a,
: 0, }); TargetSlotId = (a as LevelActivityEntity)?.SlotId,
TargetSlotGameVersion = (a as LevelActivityEntity)?.Slot.GameVersion,
TargetSlotCreatorId = includeSlotCreator ? (a as LevelActivityEntity)?.Slot.CreatorId : null,
TargetUserId = (a as UserActivityEntity)?.TargetUserId,
TargetNewsId = (a as NewsActivityEntity)?.NewsId,
TargetPlaylistId = (a as PlaylistActivityEntity)?.PlaylistId,
TargetTeamPickId =
includeTeamPick && a.Type == EventType.MMPickLevel ? (a as LevelActivityEntity)?.SlotId : null, });
} }
} }

View file

@ -6,10 +6,10 @@ using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace ProjectLighthouse.Migrations namespace LBPUnion.ProjectLighthouse.Migrations
{ {
[DbContext(typeof(DatabaseContext))] [DbContext(typeof(DatabaseContext))]
[Migration("20240120214525_InitialActivity")] [Migration("20240325034658_InitialActivity")]
public partial class InitialActivity : Migration public partial class InitialActivity : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -24,12 +24,12 @@ namespace ProjectLighthouse.Migrations
Timestamp = table.Column<DateTime>(type: "datetime(6)", nullable: false), Timestamp = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UserId = table.Column<int>(type: "int", nullable: false), UserId = table.Column<int>(type: "int", nullable: false),
Type = table.Column<int>(type: "int", nullable: false), Type = table.Column<int>(type: "int", nullable: false),
Discriminator = table.Column<string>(type: "longtext", nullable: false) Discriminator = table.Column<string>(type: "varchar(34)", maxLength: 34, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"), .Annotation("MySql:CharSet", "utf8mb4"),
CommentId = table.Column<int>(type: "int", nullable: true),
SlotId = table.Column<int>(type: "int", nullable: true), SlotId = table.Column<int>(type: "int", nullable: true),
NewsId = table.Column<int>(type: "int", nullable: true), CommentId = table.Column<int>(type: "int", nullable: true),
PhotoId = table.Column<int>(type: "int", nullable: true), PhotoId = table.Column<int>(type: "int", nullable: true),
NewsId = table.Column<int>(type: "int", nullable: true),
PlaylistId = table.Column<int>(type: "int", nullable: true), PlaylistId = table.Column<int>(type: "int", nullable: true),
ReviewId = table.Column<int>(type: "int", nullable: true), ReviewId = table.Column<int>(type: "int", nullable: true),
ScoreId = table.Column<int>(type: "int", nullable: true), ScoreId = table.Column<int>(type: "int", nullable: true),

View file

@ -27,7 +27,8 @@ namespace ProjectLighthouse.Migrations
b.Property<string>("Discriminator") b.Property<string>("Discriminator")
.IsRequired() .IsRequired()
.HasColumnType("longtext"); .HasMaxLength(34)
.HasColumnType("varchar(34)");
b.Property<DateTime>("Timestamp") b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@ -1123,18 +1124,6 @@ namespace ProjectLighthouse.Migrations
b.ToTable("WebsiteAnnouncements"); b.ToTable("WebsiteAnnouncements");
}); });
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.CommentActivityEntity", b =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("CommentId")
.HasColumnType("int");
b.HasIndex("CommentId");
b.HasDiscriminator().HasValue("CommentActivityEntity");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.LevelActivityEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.LevelActivityEntity", b =>
{ {
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity"); b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
@ -1149,6 +1138,46 @@ namespace ProjectLighthouse.Migrations
b.HasDiscriminator().HasValue("LevelActivityEntity"); b.HasDiscriminator().HasValue("LevelActivityEntity");
}); });
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.LevelCommentActivityEntity", b =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("CommentId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int");
b.Property<int>("SlotId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int")
.HasColumnName("SlotId");
b.HasIndex("CommentId");
b.HasIndex("SlotId");
b.HasDiscriminator().HasValue("LevelCommentActivityEntity");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.LevelPhotoActivity", b =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("PhotoId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int");
b.Property<int>("SlotId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int")
.HasColumnName("SlotId");
b.HasIndex("PhotoId");
b.HasIndex("SlotId");
b.HasDiscriminator().HasValue("LevelPhotoActivity");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.NewsActivityEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.NewsActivityEntity", b =>
{ {
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity"); b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
@ -1161,18 +1190,6 @@ namespace ProjectLighthouse.Migrations
b.HasDiscriminator().HasValue("NewsActivityEntity"); b.HasDiscriminator().HasValue("NewsActivityEntity");
}); });
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.PhotoActivityEntity", b =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("PhotoId")
.HasColumnType("int");
b.HasIndex("PhotoId");
b.HasDiscriminator().HasValue("PhotoActivityEntity");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.PlaylistActivityEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.PlaylistActivityEntity", b =>
{ {
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity"); b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
@ -1213,8 +1230,15 @@ namespace ProjectLighthouse.Migrations
b.Property<int>("ReviewId") b.Property<int>("ReviewId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int>("SlotId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int")
.HasColumnName("SlotId");
b.HasIndex("ReviewId"); b.HasIndex("ReviewId");
b.HasIndex("SlotId");
b.HasDiscriminator().HasValue("ReviewActivityEntity"); b.HasDiscriminator().HasValue("ReviewActivityEntity");
}); });
@ -1225,8 +1249,15 @@ namespace ProjectLighthouse.Migrations
b.Property<int>("ScoreId") b.Property<int>("ScoreId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int>("SlotId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int")
.HasColumnName("SlotId");
b.HasIndex("ScoreId"); b.HasIndex("ScoreId");
b.HasIndex("SlotId");
b.HasDiscriminator().HasValue("ScoreActivityEntity"); b.HasDiscriminator().HasValue("ScoreActivityEntity");
}); });
@ -1235,13 +1266,55 @@ namespace ProjectLighthouse.Migrations
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity"); b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("TargetUserId") b.Property<int>("TargetUserId")
.HasColumnType("int"); .ValueGeneratedOnUpdateSometimes()
.HasColumnType("int")
.HasColumnName("TargetUserId");
b.HasIndex("TargetUserId"); b.HasIndex("TargetUserId");
b.HasDiscriminator().HasValue("UserActivityEntity"); b.HasDiscriminator().HasValue("UserActivityEntity");
}); });
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.UserCommentActivityEntity", b =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("CommentId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int");
b.Property<int>("TargetUserId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int")
.HasColumnName("TargetUserId");
b.HasIndex("CommentId");
b.HasIndex("TargetUserId");
b.HasDiscriminator().HasValue("UserCommentActivityEntity");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.UserPhotoActivity", b =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("PhotoId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int");
b.Property<int>("TargetUserId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int")
.HasColumnName("TargetUserId");
b.HasIndex("PhotoId");
b.HasIndex("TargetUserId");
b.HasDiscriminator().HasValue("UserPhotoActivity");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity", b =>
{ {
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User") b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User")
@ -1646,17 +1719,6 @@ namespace ProjectLighthouse.Migrations
b.Navigation("Publisher"); b.Navigation("Publisher");
}); });
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.CommentActivityEntity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.CommentEntity", "Comment")
.WithMany()
.HasForeignKey("CommentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Comment");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.LevelActivityEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.LevelActivityEntity", b =>
{ {
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot") b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot")
@ -1668,6 +1730,44 @@ namespace ProjectLighthouse.Migrations
b.Navigation("Slot"); b.Navigation("Slot");
}); });
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.LevelCommentActivityEntity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.CommentEntity", "Comment")
.WithMany()
.HasForeignKey("CommentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot")
.WithMany()
.HasForeignKey("SlotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Comment");
b.Navigation("Slot");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.LevelPhotoActivity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", "Photo")
.WithMany()
.HasForeignKey("PhotoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot")
.WithMany()
.HasForeignKey("SlotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Photo");
b.Navigation("Slot");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.NewsActivityEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.NewsActivityEntity", b =>
{ {
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Website.WebsiteAnnouncementEntity", "News") b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Website.WebsiteAnnouncementEntity", "News")
@ -1679,17 +1779,6 @@ namespace ProjectLighthouse.Migrations
b.Navigation("News"); b.Navigation("News");
}); });
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.PhotoActivityEntity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", "Photo")
.WithMany()
.HasForeignKey("PhotoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Photo");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.PlaylistActivityEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.PlaylistActivityEntity", b =>
{ {
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.PlaylistEntity", "Playlist") b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.PlaylistEntity", "Playlist")
@ -1720,7 +1809,15 @@ namespace ProjectLighthouse.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot")
.WithMany()
.HasForeignKey("SlotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Review"); b.Navigation("Review");
b.Navigation("Slot");
}); });
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ScoreActivityEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ScoreActivityEntity", b =>
@ -1731,7 +1828,15 @@ namespace ProjectLighthouse.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot")
.WithMany()
.HasForeignKey("SlotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Score"); b.Navigation("Score");
b.Navigation("Slot");
}); });
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.UserActivityEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.UserActivityEntity", b =>
@ -1745,6 +1850,44 @@ namespace ProjectLighthouse.Migrations
b.Navigation("TargetUser"); b.Navigation("TargetUser");
}); });
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.UserCommentActivityEntity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.CommentEntity", "Comment")
.WithMany()
.HasForeignKey("CommentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "TargetUser")
.WithMany()
.HasForeignKey("TargetUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Comment");
b.Navigation("TargetUser");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.UserPhotoActivity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", "Photo")
.WithMany()
.HasForeignKey("PhotoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "TargetUser")
.WithMany()
.HasForeignKey("TargetUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Photo");
b.Navigation("TargetUser");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", b => modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", b =>
{ {
b.Navigation("PhotoSubjects"); b.Navigation("PhotoSubjects");

View file

@ -25,11 +25,11 @@ public class ActivityDto
}; };
public ActivityGroupType GroupType => public ActivityGroupType GroupType =>
this.TargetSlotId != 0 this.TargetSlotId != null
? ActivityGroupType.Level ? ActivityGroupType.Level
: this.TargetUserId != 0 : this.TargetUserId != null
? ActivityGroupType.User ? ActivityGroupType.User
: this.TargetPlaylistId != 0 : this.TargetPlaylistId != null
? ActivityGroupType.Playlist ? ActivityGroupType.Playlist
: ActivityGroupType.News; : ActivityGroupType.News;
} }

View file

@ -41,40 +41,44 @@ public class ActivityEntityEventHandler : IEntityEventHandler
{ {
CommentType.Level => comment.TargetSlot?.Type switch CommentType.Level => comment.TargetSlot?.Type switch
{ {
SlotType.User => new CommentActivityEntity SlotType.User => new LevelCommentActivityEntity
{ {
Type = EventType.CommentOnLevel, Type = EventType.CommentOnLevel,
CommentId = comment.CommentId, CommentId = comment.CommentId,
UserId = comment.PosterUserId, UserId = comment.PosterUserId,
SlotId = comment.TargetSlotId ?? throw new NullReferenceException("SlotId in Level comment is null, this shouldn't happen."),
}, },
_ => null, _ => null,
}, },
CommentType.Profile => new CommentActivityEntity CommentType.Profile => new UserCommentActivityEntity()
{ {
Type = EventType.CommentOnUser, Type = EventType.CommentOnUser,
CommentId = comment.CommentId, CommentId = comment.CommentId,
UserId = comment.PosterUserId, UserId = comment.PosterUserId,
TargetUserId = comment.TargetUserId ?? throw new NullReferenceException("TargetUserId in User comment is null, this shouldn't happen."),
}, },
_ => null, _ => null,
}, },
PhotoEntity photo => photo.SlotId switch PhotoEntity photo => photo.SlotId switch
{ {
// Photos without levels // Photos without levels
null => new PhotoActivityEntity null => new UserPhotoActivity
{ {
Type = EventType.UploadPhoto, Type = EventType.UploadPhoto,
PhotoId = photo.PhotoId, PhotoId = photo.PhotoId,
UserId = photo.CreatorId, UserId = photo.CreatorId,
TargetUserId = photo.CreatorId,
}, },
_ => photo.Slot?.Type switch _ => photo.Slot?.Type switch
{ {
SlotType.Developer => null, SlotType.Developer => null,
// Non-story levels (moon, pod, etc) // Non-story levels (moon, pod, etc)
_ => new PhotoActivityEntity _ => new LevelPhotoActivity
{ {
Type = EventType.UploadPhoto, Type = EventType.UploadPhoto,
PhotoId = photo.PhotoId, PhotoId = photo.PhotoId,
UserId = photo.CreatorId, UserId = photo.CreatorId,
SlotId = photo.SlotId ?? throw new NullReferenceException("SlotId in Photo is null"),
}, },
}, },
}, },
@ -86,6 +90,7 @@ public class ActivityEntityEventHandler : IEntityEventHandler
Type = EventType.Score, Type = EventType.Score,
ScoreId = score.ScoreId, ScoreId = score.ScoreId,
UserId = score.UserId, UserId = score.UserId,
SlotId = score.SlotId,
}, },
_ => null, _ => null,
}, },
@ -122,6 +127,7 @@ public class ActivityEntityEventHandler : IEntityEventHandler
Type = EventType.ReviewLevel, Type = EventType.ReviewLevel,
ReviewId = review.ReviewId, ReviewId = review.ReviewId,
UserId = review.ReviewerId, UserId = review.ReviewerId,
SlotId = review.SlotId,
}, },
RatedLevelEntity ratedLevel => new LevelActivityEntity RatedLevelEntity ratedLevel => new LevelActivityEntity
{ {

View file

@ -3,7 +3,12 @@
namespace LBPUnion.ProjectLighthouse.Types.Activity; namespace LBPUnion.ProjectLighthouse.Types.Activity;
/// <summary> /// <summary>
/// UnheartLevel, UnheartUser, DeleteLevelComment, and UnpublishLevel don't actually do anything /// An enum of all possible event types that LBP recognizes in Recent Activity
/// <remarks>
/// <para>
/// <see cref="UnheartLevel"/>, <see cref="UnheartUser"/>, <see cref="DeleteLevelComment"/>, <see cref="UnpublishLevel"/> are ignored by the game
/// </para>
/// </remarks>
/// </summary> /// </summary>
public enum EventType public enum EventType
{ {
@ -61,12 +66,21 @@ public enum EventType
[XmlEnum("comment_on_user")] [XmlEnum("comment_on_user")]
CommentOnUser = 17, CommentOnUser = 17,
/// <remarks>
/// This event is only used in LBP3
/// </remarks>>
[XmlEnum("create_playlist")] [XmlEnum("create_playlist")]
CreatePlaylist = 18, CreatePlaylist = 18,
/// <remarks>
/// This event is only used in LBP3
/// </remarks>>
[XmlEnum("heart_playlist")] [XmlEnum("heart_playlist")]
HeartPlaylist = 19, HeartPlaylist = 19,
/// <remarks>
/// This event is only used in LBP3
/// </remarks>>
[XmlEnum("add_level_to_playlist")] [XmlEnum("add_level_to_playlist")]
AddLevelToPlaylist = 20, AddLevelToPlaylist = 20,
} }

View file

@ -11,12 +11,21 @@ public class ActivityEntity
[Key] [Key]
public int ActivityId { get; set; } public int ActivityId { get; set; }
/// <summary>
/// The time that this event took place.
/// </summary>
public DateTime Timestamp { get; set; } public DateTime Timestamp { get; set; }
/// <summary>
/// The <see cref="UserEntity.UserId"/> of the <see cref="UserEntity"/> that triggered this event.
/// </summary>
public int UserId { get; set; } public int UserId { get; set; }
[ForeignKey(nameof(UserId))] [ForeignKey(nameof(UserId))]
public UserEntity User { get; set; } public UserEntity User { get; set; }
/// <summary>
/// The type of this event.
/// </summary>
public EventType Type { get; set; } public EventType Type { get; set; }
} }

View file

@ -1,15 +1,38 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile; using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity; namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
/// <summary> /// <summary>
/// Supported event types: CommentOnUser, CommentOnLevel, DeleteLevelComment /// Supported event types: <see cref="EventType.CommentOnUser"/>, <see cref="EventType.CommentOnLevel"/>, and <see cref="EventType.DeleteLevelComment"/>.
/// </summary> /// </summary>
public class CommentActivityEntity : ActivityEntity public class CommentActivityEntity : ActivityEntity
{ {
/// <summary>
/// The <see cref="CommentEntity.CommentId"/> of the <see cref="CommentEntity"/> that this event refers to.
/// </summary>
public int CommentId { get; set; } public int CommentId { get; set; }
[ForeignKey(nameof(CommentId))] [ForeignKey(nameof(CommentId))]
public CommentEntity Comment { get; set; } public CommentEntity Comment { get; set; }
} }
public class LevelCommentActivityEntity : CommentActivityEntity
{
[Column("SlotId")]
public int SlotId { get; set; }
[ForeignKey(nameof(SlotId))]
public SlotEntity Slot { get; set; }
}
public class UserCommentActivityEntity : CommentActivityEntity
{
[Column("TargetUserId")]
public int TargetUserId { get; set; }
[ForeignKey(nameof(TargetUserId))]
public UserEntity TargetUser { get; set; }
}

View file

@ -1,13 +1,18 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Level;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity; namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
/// <summary> /// <summary>
/// Supported event types: play_level, heart_level, publish_level, unheart_level, dpad_rate_level, rate_level, tag_level, mm_pick_level /// Supported event types: <see cref="EventType.PlayLevel"/>, <see cref="EventType.HeartLevel"/>, <see cref="EventType.PublishLevel"/>,
/// <see cref="EventType.UnheartLevel"/>, and <see cref="EventType.MMPickLevel"/>.
/// </summary> /// </summary>
public class LevelActivityEntity : ActivityEntity public class LevelActivityEntity : ActivityEntity
{ {
/// <summary>
/// The <see cref="SlotEntity.SlotId"/> of the <see cref="SlotEntity"/> that this event refers to.
/// </summary>
[Column("SlotId")] [Column("SlotId")]
public int SlotId { get; set; } public int SlotId { get; set; }

View file

@ -1,13 +1,22 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Website; using LBPUnion.ProjectLighthouse.Types.Entities.Website;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity; namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
/// <summary> /// <summary>
/// Supported event types: NewsPost /// Supported event types: <see cref="EventType.NewsPost"/>.
/// <remarks>
/// <para>
/// This event type can only be grouped with other <see cref="NewsActivityEntity"/>.
/// </para>
/// </remarks>
/// </summary> /// </summary>
public class NewsActivityEntity : ActivityEntity public class NewsActivityEntity : ActivityEntity
{ {
/// <summary>
/// The <see cref="WebsiteAnnouncementEntity.AnnouncementId"/> of the <see cref="WebsiteAnnouncementEntity"/> that this event refers to.
/// </summary>
public int NewsId { get; set; } public int NewsId { get; set; }
[ForeignKey(nameof(NewsId))] [ForeignKey(nameof(NewsId))]

View file

@ -1,16 +1,38 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile; using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity; namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
/// <summary> /// <summary>
/// Supported event types: UploadPhoto /// Supported event types: <see cref="EventType.UploadPhoto"/>.
/// </summary> /// </summary>
public class PhotoActivityEntity : ActivityEntity public class PhotoActivityEntity : ActivityEntity
{ {
/// <summary>
/// The <see cref="PhotoEntity.PhotoId"/> of the <see cref="PhotoEntity"/> that this event refers to.
/// </summary>
public int PhotoId { get; set; } public int PhotoId { get; set; }
[ForeignKey(nameof(PhotoId))] [ForeignKey(nameof(PhotoId))]
public PhotoEntity Photo { get; set; } public PhotoEntity Photo { get; set; }
}
public class LevelPhotoActivity : PhotoActivityEntity
{
[Column("SlotId")]
public int SlotId { get; set; }
[ForeignKey(nameof(SlotId))]
public SlotEntity Slot { get; set; }
}
public class UserPhotoActivity : PhotoActivityEntity
{
[Column("TargetUserId")]
public int TargetUserId { get; set; }
[ForeignKey(nameof(TargetUserId))]
public UserEntity TargetUser { get; set; }
} }

View file

@ -1,13 +1,17 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Level;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity; namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
/// <summary> /// <summary>
/// Supported event types: CreatePlaylist, HeartPlaylist /// Supported event types: <see cref="EventType.CreatePlaylist"/> and <see cref="EventType.HeartPlaylist"/>.
/// </summary> /// </summary>
public class PlaylistActivityEntity : ActivityEntity public class PlaylistActivityEntity : ActivityEntity
{ {
/// <summary>
/// The <see cref="PlaylistEntity.PlaylistId"/> of the <see cref="PlaylistEntity"/> that this event refers to.
/// </summary>
[Column("PlaylistId")] [Column("PlaylistId")]
public int PlaylistId { get; set; } public int PlaylistId { get; set; }
@ -16,15 +20,19 @@ public class PlaylistActivityEntity : ActivityEntity
} }
/// <summary> /// <summary>
/// Supported event types: AddLevelToPlaylist /// Supported event types: <see cref="EventType.AddLevelToPlaylist"/>.
/// <remarks>
/// <para> /// <para>
/// The relationship between <see cref="PlaylistActivityEntity"/> and <see cref="PlaylistWithSlotActivityEntity"/> /// The relationship between <see cref="PlaylistActivityEntity"/> and <see cref="PlaylistWithSlotActivityEntity"/>
/// is slightly hacky but it allows conditional reuse of columns from other ActivityEntity's /// is slightly hacky but it allows us to reuse columns that would normally only be user with other <see cref="ActivityEntity"/> types.
///
/// </para> /// </para>
/// </remarks>
/// </summary> /// </summary>
public class PlaylistWithSlotActivityEntity : ActivityEntity public class PlaylistWithSlotActivityEntity : ActivityEntity
{ {
/// <summary>
/// The <see cref="PlaylistEntity.PlaylistId"/> of the <see cref="PlaylistEntity"/> that this event refers to.
/// </summary>
[Column("PlaylistId")] [Column("PlaylistId")]
public int PlaylistId { get; set; } public int PlaylistId { get; set; }
@ -33,7 +41,11 @@ public class PlaylistWithSlotActivityEntity : ActivityEntity
/// <summary> /// <summary>
/// This reuses the SlotId column of <see cref="LevelActivityEntity"/> but has no ForeignKey definition so that it can be null /// This reuses the SlotId column of <see cref="LevelActivityEntity"/> but has no ForeignKey definition so that it can be null
/// <para>It effectively serves as extra storage for PlaylistActivityEntity to use for the AddLevelToPlaylistEvent</para> /// <remarks>
/// <para>
/// It effectively serves as extra storage for PlaylistActivityEntity to use for the AddLevelToPlaylistEvent
/// </para>
/// </remarks>
/// </summary> /// </summary>
[Column("SlotId")] [Column("SlotId")]
public int SlotId { get; set; } public int SlotId { get; set; }

View file

@ -1,15 +1,25 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Level;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity; namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
/// <summary> /// <summary>
/// Supported event types: DpadRateLevel, ReviewLevel, RateLevel, TagLevel /// Supported event types: <see cref="EventType.DpadRateLevel"/>, <see cref="EventType.ReviewLevel"/>, <see cref="EventType.RateLevel"/>, and <see cref="EventType.TagLevel"/>.
/// </summary> /// </summary>
public class ReviewActivityEntity : ActivityEntity public class ReviewActivityEntity : ActivityEntity
{ {
/// <summary>
/// The <see cref="ReviewEntity.ReviewId"/> of the <see cref="ReviewEntity"/> that this event refers to.
/// </summary>
public int ReviewId { get; set; } public int ReviewId { get; set; }
[ForeignKey(nameof(ReviewId))] [ForeignKey(nameof(ReviewId))]
public ReviewEntity Review { get; set; } public ReviewEntity Review { get; set; }
[Column("SlotId")]
public int SlotId { get; set; }
[ForeignKey(nameof(SlotId))]
public SlotEntity Slot { get; set; }
} }

View file

@ -1,15 +1,25 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Level; using LBPUnion.ProjectLighthouse.Types.Entities.Level;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity; namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
/// <summary> /// <summary>
/// Supported event types: Score /// Supported event types: <see cref="EventType.Score"/>.
/// </summary> /// </summary>
public class ScoreActivityEntity : ActivityEntity public class ScoreActivityEntity : ActivityEntity
{ {
/// <summary>
/// The <see cref="ScoreEntity.ScoreId"/> of the <see cref="ScoreEntity"/> that this event refers to.
/// </summary>
public int ScoreId { get; set; } public int ScoreId { get; set; }
[ForeignKey(nameof(ScoreId))] [ForeignKey(nameof(ScoreId))]
public ScoreEntity Score { get; set; } public ScoreEntity Score { get; set; }
[Column("SlotId")]
public int SlotId { get; set; }
[ForeignKey(nameof(SlotId))]
public SlotEntity Slot { get; set; }
} }

View file

@ -1,13 +1,18 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile; using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity; namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
/// <summary> /// <summary>
/// Supported event types: HeartUser, UnheartUser /// Supported event types: <see cref="EventType.HeartUser"/> and <see cref="EventType.UnheartUser"/>.
/// </summary> /// </summary>
public class UserActivityEntity : ActivityEntity public class UserActivityEntity : ActivityEntity
{ {
/// <summary>
/// The <see cref="UserEntity.UserId"/> of the <see cref="UserEntity"/> that this event refers to.
/// </summary>
[Column("TargetUserId")]
public int TargetUserId { get; set; } public int TargetUserId { get; set; }
[ForeignKey(nameof(TargetUserId))] [ForeignKey(nameof(TargetUserId))]

View file

@ -58,7 +58,7 @@ public class GameEvent : ILbpSerializable, INeedsPreparationForSerialization
public static IEnumerable<GameEvent> CreateFromActivities(IEnumerable<ActivityDto> activities) public static IEnumerable<GameEvent> CreateFromActivities(IEnumerable<ActivityDto> activities)
{ {
List<GameEvent> events = new(); List<GameEvent> events = [];
List<IGrouping<EventType, ActivityDto>> typeGroups = activities.GroupBy(g => g.Activity.Type).ToList(); List<IGrouping<EventType, ActivityDto>> typeGroups = activities.GroupBy(g => g.Activity.Type).ToList();
foreach (IGrouping<EventType, ActivityDto> typeGroup in typeGroups) foreach (IGrouping<EventType, ActivityDto> typeGroup in typeGroups)
{ {

View file

@ -26,21 +26,34 @@ namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity;
[XmlRoot("stream")] [XmlRoot("stream")]
public class GameStream : ILbpSerializable, INeedsPreparationForSerialization public class GameStream : ILbpSerializable, INeedsPreparationForSerialization
{ {
/// <summary>
/// A list of <see cref="SlotEntity.SlotId"/> that should be included in the root
/// of the stream object. These will be loaded into <see cref="Slots"/>
/// </summary>
[XmlIgnore] [XmlIgnore]
public List<int> SlotIds { get; set; } public List<int> SlotIds { get; set; }
/// <summary>
/// A list of <see cref="UserEntity.UserId"/> that should be included in the root
/// of the stream object. These will be loaded into <see cref="Users"/>
/// </summary>
[XmlIgnore] [XmlIgnore]
public List<int> UserIds { get; set; } public List<int> UserIds { get; set; }
/// <summary>
/// A list of <see cref="PlaylistEntity.PlaylistId"/> that should be included in the root
/// of the stream object. These will be loaded into <see cref="Playlists"/>
/// </summary>
[XmlIgnore] [XmlIgnore]
public List<int> PlaylistIds { get; set; } public List<int> PlaylistIds { get; set; }
/// <summary>
/// A list of <see cref="WebsiteAnnouncementEntity.AnnouncementId"/> that should be included in the root
/// of the stream object. These will be loaded into <see cref="News"/>
/// </summary>
[XmlIgnore] [XmlIgnore]
public List<int> NewsIds { get; set; } public List<int> NewsIds { get; set; }
[XmlIgnore]
private int TargetUserId { get; set; }
[XmlIgnore] [XmlIgnore]
private GameVersion TargetGame { get; set; } private GameVersion TargetGame { get; set; }
@ -77,7 +90,7 @@ public class GameStream : ILbpSerializable, INeedsPreparationForSerialization
public async Task PrepareSerialization(DatabaseContext database) public async Task PrepareSerialization(DatabaseContext database)
{ {
this.Slots = await LoadEntities<SlotEntity, SlotBase>(this.SlotIds, slot => SlotBase.CreateFromEntity(slot, this.TargetGame, this.TargetUserId), s => s.Type == SlotType.User); this.Slots = await LoadEntities<SlotEntity, SlotBase>(this.SlotIds, slot => SlotBase.CreateFromEntity(slot, this.TargetGame, 0), s => s.Type == SlotType.User);
this.Users = await LoadEntities<UserEntity, GameUser>(this.UserIds, user => GameUser.CreateFromEntity(user, this.TargetGame)); this.Users = await LoadEntities<UserEntity, GameUser>(this.UserIds, user => GameUser.CreateFromEntity(user, this.TargetGame));
this.Playlists = await LoadEntities<PlaylistEntity, GamePlaylist>(this.PlaylistIds, GamePlaylist.CreateFromEntity); this.Playlists = await LoadEntities<PlaylistEntity, GamePlaylist>(this.PlaylistIds, GamePlaylist.CreateFromEntity);
this.News = await LoadEntities<WebsiteAnnouncementEntity, GameNewsObject>(this.NewsIds, a => GameNewsObject.CreateFromEntity(a, this.TargetGame)); this.News = await LoadEntities<WebsiteAnnouncementEntity, GameNewsObject>(this.NewsIds, a => GameNewsObject.CreateFromEntity(a, this.TargetGame));
@ -86,16 +99,16 @@ public class GameStream : ILbpSerializable, INeedsPreparationForSerialization
async Task<List<TResult>> LoadEntities<TFrom, TResult>(List<int> ids, Func<TFrom, TResult> transformation, Func<TFrom, bool> predicate = null) async Task<List<TResult>> LoadEntities<TFrom, TResult>(List<int> ids, Func<TFrom, TResult> transformation, Func<TFrom, bool> predicate = null)
where TFrom : class where TFrom : class
{ {
List<TResult> results = new(); List<TResult> results = [];
if (ids.Count <= 0) return null; if (ids.Count <= 0) return null;
foreach (int id in ids) foreach (int id in ids)
{ {
TFrom entity = await database.Set<TFrom>().FindAsync(id); TFrom entity = await database.Set<TFrom>().FindAsync(id);
if (predicate != null && !predicate(entity)) continue;
if (entity == null) continue; if (entity == null) continue;
if (predicate != null && !predicate(entity)) continue;
results.Add(transformation(entity)); results.Add(transformation(entity));
} }
@ -108,7 +121,6 @@ public class GameStream : ILbpSerializable, INeedsPreparationForSerialization
{ {
GameStream gameStream = new() GameStream gameStream = new()
{ {
TargetUserId = token.UserId,
TargetGame = token.GameVersion, TargetGame = token.GameVersion,
StartTimestamp = startTimestamp, StartTimestamp = startTimestamp,
EndTimestamp = endTimestamp, EndTimestamp = endTimestamp,

View file

@ -37,8 +37,6 @@ public class GameStreamGroup : ILbpSerializable
[XmlArray("events")] [XmlArray("events")]
[XmlArrayItem("event")] [XmlArrayItem("event")]
[DefaultValue(null)] [DefaultValue(null)]
// ReSharper disable once MemberCanBePrivate.Global
// (the serializer can't see this if it's private)
public List<GameEvent> Events { get; set; } public List<GameEvent> Events { get; set; }
public static GameStreamGroup CreateFromGroup(OuterActivityGroup group) public static GameStreamGroup CreateFromGroup(OuterActivityGroup group)
@ -56,8 +54,7 @@ public class GameStreamGroup : ILbpSerializable
g.Key.TargetId, g.Key.TargetId,
streamGroup => streamGroup =>
{ {
streamGroup.Timestamp = streamGroup.Timestamp = g.Max(a => a.Activity.Timestamp).ToUnixTimeMilliseconds();
g.MaxBy(a => a.Activity.Timestamp).Activity.Timestamp.ToUnixTimeMilliseconds();
streamGroup.Events = GameEvent.CreateFromActivities(g).ToList(); streamGroup.Events = GameEvent.CreateFromActivities(g).ToList();
})) }))
.ToList()); .ToList());