Finish most of Recent Activity

This commit is contained in:
Slendy 2023-07-28 17:45:28 -05:00
commit 60d851fb15
No known key found for this signature in database
GPG key ID: 7288D68361B91428
77 changed files with 2725 additions and 443 deletions

View file

@ -1,6 +1,6 @@
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Filter;
using LBPUnion.ProjectLighthouse.Filter.Filters;
using LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Servers.API.Responses;
using LBPUnion.ProjectLighthouse.Types.Users;

View file

@ -1,11 +1,10 @@
using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Filter.Filters.Activity;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.StorableLists.Stores;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Entities.Token;
using LBPUnion.ProjectLighthouse.Types.Levels;
using LBPUnion.ProjectLighthouse.Types.Serialization.Activity;
@ -29,143 +28,210 @@ public class ActivityController : ControllerBase
this.database = database;
}
public class ActivityDto
{
public required ActivityEntity Activity { get; set; }
public int? TargetSlotId { get; set; }
public int? TargetUserId { get; set; }
public int? TargetPlaylistId { get; set; }
public int? SlotCreatorId { get; set; }
}
//TODO refactor this mess into a separate db file or something
private static Expression<Func<ActivityEntity, ActivityDto>> ActivityToDto()
{
return a => new ActivityDto
{
Activity = a,
TargetSlotId = a is LevelActivityEntity
? ((LevelActivityEntity)a).SlotId
: a is PhotoActivityEntity && ((PhotoActivityEntity)a).Photo.PhotoId != 0
? ((PhotoActivityEntity)a).Photo.SlotId
: a is CommentActivityEntity && ((CommentActivityEntity)a).Comment.Type == CommentType.Level
? ((CommentActivityEntity)a).Comment.TargetId
: a is ScoreActivityEntity
? ((ScoreActivityEntity)a).Score.SlotId
: 0,
TargetUserId = a is UserActivityEntity
? ((UserActivityEntity)a).TargetUserId
: a is CommentActivityEntity && ((CommentActivityEntity)a).Comment.Type == CommentType.Profile
? ((CommentActivityEntity)a).Comment.TargetId
: a is PhotoActivityEntity && ((PhotoActivityEntity)a).Photo.SlotId != 0
? ((PhotoActivityEntity)a).Photo.CreatorId
: 0,
TargetPlaylistId = a is PlaylistActivityEntity ? ((PlaylistActivityEntity)a).PlaylistId : 0,
};
}
private static IQueryable<IGrouping<ActivityGroup, ActivityEntity>> GroupActivities
(IQueryable<ActivityEntity> activityQuery)
{
return activityQuery.Select(ActivityToDto())
.GroupBy(dto => new ActivityGroup
{
Timestamp = dto.Activity.Timestamp.Date,
UserId = dto.Activity.UserId,
TargetUserId = dto.TargetUserId,
TargetSlotId = dto.TargetSlotId,
TargetPlaylistId = dto.TargetPlaylistId,
},
dto => dto.Activity);
}
private static IQueryable<IGrouping<ActivityGroup, ActivityEntity>> GroupActivities
(IQueryable<ActivityDto> activityQuery)
{
return activityQuery.GroupBy(dto => new ActivityGroup
{
Timestamp = dto.Activity.Timestamp.Date,
UserId = dto.Activity.UserId,
TargetUserId = dto.TargetUserId,
TargetSlotId = dto.TargetSlotId,
TargetPlaylistId = dto.TargetPlaylistId,
},
dto => dto.Activity);
}
// TODO this is kinda ass, can maybe improve once comment migration is merged
private async Task<IQueryable<ActivityEntity>> GetFilters
/// <summary>
/// This method is only used for LBP2 so we exclude playlists
/// </summary>
private async Task<IQueryable<ActivityDto>> GetFilters
(
IQueryable<ActivityDto> dtoQuery,
GameTokenEntity token,
bool excludeNews,
bool excludeMyLevels,
bool excludeFriends,
bool excludeFavouriteUsers,
bool excludeMyself
bool excludeMyself,
bool excludeMyPlaylists = true
)
{
IQueryable<ActivityEntity> query = this.database.Activities.AsQueryable();
if (excludeNews) query = query.Where(a => a.Type != EventType.NewsPost);
IQueryable<ActivityDto> dtoQuery = query.Select(a => new ActivityDto
{
Activity = a,
SlotCreatorId = 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.TargetId
: a is ScoreActivityEntity
? ((ScoreActivityEntity)a).Score.Slot.CreatorId
: 0,
});
Expression<Func<ActivityDto, bool>> predicate = PredicateExtensions.False<ActivityDto>();
predicate = predicate.Or(a => a.SlotCreatorId == 0 || excludeMyLevels
? a.SlotCreatorId != token.UserId
: a.SlotCreatorId == token.UserId);
List<int>? friendIds = UserFriendStore.GetUserFriendData(token.UserId)?.FriendIds;
if (friendIds != null)
{
predicate = excludeFriends
? predicate.Or(a => !friendIds.Contains(a.Activity.UserId))
: predicate.Or(a => friendIds.Contains(a.Activity.UserId));
}
List<int> favouriteUsers = await this.database.HeartedProfiles.Where(hp => hp.UserId == token.UserId)
.Select(hp => hp.HeartedUserId)
.ToListAsync();
predicate = excludeFavouriteUsers
? predicate.Or(a => !favouriteUsers.Contains(a.Activity.UserId))
: predicate.Or(a => favouriteUsers.Contains(a.Activity.UserId));
List<int>? friendIds = UserFriendStore.GetUserFriendData(token.UserId)?.FriendIds;
friendIds ??= new List<int>();
predicate = excludeMyself
? predicate.Or(a => a.Activity.UserId != token.UserId)
: predicate.Or(a => a.Activity.UserId == token.UserId);
// This is how lbp3 does its filtering
GameStreamFilter? filter = await this.DeserializeBody<GameStreamFilter>();
if (filter?.Sources != null)
{
foreach (GameStreamFilterEventSource filterSource in filter.Sources.Where(filterSource =>
filterSource.SourceType != null && filterSource.Types?.Count != 0))
{
EventType[] types = filterSource.Types?.ToArray() ?? Array.Empty<EventType>();
EventTypeFilter eventFilter = new(types);
predicate = filterSource.SourceType switch
{
"MyLevels" => predicate.Or(new MyLevelActivityFilter(token.UserId, eventFilter).GetPredicate()),
"FavouriteUsers" => predicate.Or(
new IncludeUserIdFilter(favouriteUsers, eventFilter).GetPredicate()),
"Friends" => predicate.Or(new IncludeUserIdFilter(friendIds, eventFilter).GetPredicate()),
_ => predicate,
};
}
}
query = dtoQuery.Where(predicate).Select(dto => dto.Activity);
Expression<Func<ActivityDto, bool>> newsPredicate = !excludeNews
? new IncludeNewsFilter().GetPredicate()
: new ExcludeNewsFilter().GetPredicate();
return query.OrderByDescending(a => a.Timestamp);
predicate = predicate.Or(newsPredicate);
if (!excludeMyLevels)
{
predicate = predicate.Or(dto => dto.TargetSlotCreatorId == token.UserId);
}
List<int> includedUserIds = new();
if (!excludeFriends)
{
includedUserIds.AddRange(friendIds);
}
if (!excludeFavouriteUsers)
{
includedUserIds.AddRange(favouriteUsers);
}
if (!excludeMyself)
{
includedUserIds.Add(token.UserId);
}
predicate = predicate.Or(dto => includedUserIds.Contains(dto.Activity.UserId));
if (!excludeMyPlaylists)
{
List<int> creatorPlaylists = await this.database.Playlists.Where(p => p.CreatorId == token.UserId)
.Select(p => p.PlaylistId)
.ToListAsync();
predicate = predicate.Or(new PlaylistActivityFilter(creatorPlaylists).GetPredicate());
}
else
{
predicate = predicate.And(dto =>
dto.Activity.Type != EventType.CreatePlaylist &&
dto.Activity.Type != EventType.HeartPlaylist &&
dto.Activity.Type != EventType.AddLevelToPlaylist);
}
Console.WriteLine(predicate);
dtoQuery = dtoQuery.Where(predicate);
return dtoQuery;
}
public Task<DateTime> GetMostRecentEventTime(GameTokenEntity token, DateTime upperBound)
public Task<DateTime> GetMostRecentEventTime(IQueryable<ActivityDto> activity, DateTime upperBound)
{
return this.database.Activities.Where(a => a.UserId == token.UserId)
.Where(a => a.Timestamp < upperBound)
.OrderByDescending(a => a.Timestamp)
.Select(a => a.Timestamp)
return activity.OrderByDescending(a => a.Activity.Timestamp)
.Where(a => a.Activity.Timestamp < upperBound)
.Select(a => a.Activity.Timestamp)
.FirstOrDefaultAsync();
}
private async Task<(DateTime Start, DateTime End)> GetTimeBounds
(IQueryable<ActivityDto> activityQuery, long? startTime, long? endTime)
{
if (startTime is null or 0) startTime = TimeHelper.TimestampMillis;
DateTime start = DateTimeExtensions.FromUnixTimeMilliseconds(startTime.Value);
DateTime end;
if (endTime == null)
{
end = await this.GetMostRecentEventTime(activityQuery, start);
// If there is no recent event then set it to the the start
if (end == DateTime.MinValue) end = start;
end = end.Subtract(TimeSpan.FromDays(7));
}
else
{
end = DateTimeExtensions.FromUnixTimeMilliseconds(endTime.Value);
// Don't allow more than 7 days worth of activity in a single page
if (start.Subtract(end).TotalDays > 7)
{
end = start.Subtract(TimeSpan.FromDays(7));
}
}
return (start, end);
}
private static DateTime GetOldestTime
(IReadOnlyCollection<IGrouping<ActivityGroup, ActivityDto>> groups, DateTime defaultTimestamp) =>
groups.Any()
? groups.Min(g => g.MinBy(a => a.Activity.Timestamp)?.Activity.Timestamp ?? defaultTimestamp)
: defaultTimestamp;
/// <summary>
/// Speeds up serialization because many nested entities need to find Slots by id
/// and since they use the Find() method they can benefit from having the entities
/// already tracked by the context
/// </summary>
private async Task CacheEntities(IReadOnlyCollection<OuterActivityGroup> groups)
{
List<int> slotIds = groups.GetIds(ActivityGroupType.Level);
List<int> userIds = groups.GetIds(ActivityGroupType.User);
List<int> playlistIds = groups.GetIds(ActivityGroupType.Playlist);
List<int> newsIds = groups.GetIds(ActivityGroupType.News);
// Cache target levels and users within DbContext
if (slotIds.Count > 0) await this.database.Slots.Where(s => slotIds.Contains(s.SlotId)).LoadAsync();
if (userIds.Count > 0) await this.database.Users.Where(u => userIds.Contains(u.UserId)).LoadAsync();
if (playlistIds.Count > 0)
await this.database.Playlists.Where(p => playlistIds.Contains(p.PlaylistId)).LoadAsync();
if (newsIds.Count > 0)
await this.database.WebsiteAnnouncements.Where(a => newsIds.Contains(a.AnnouncementId)).LoadAsync();
}
/// <summary>
/// LBP3 uses a different grouping format that wants the actor to be the top level group and the events should be the subgroups
/// </summary>
[HttpPost]
public async Task<IActionResult> GlobalActivityLBP3
(long timestamp, bool excludeMyPlaylists, bool excludeNews, bool excludeMyself)
{
GameTokenEntity token = this.GetToken();
if (token.GameVersion != GameVersion.LittleBigPlanet3) return this.NotFound();
IQueryable<ActivityDto> activityEvents = await this.GetFilters(
this.database.Activities.ToActivityDto(true, true),
token,
excludeNews,
true,
true,
true,
excludeMyself,
excludeMyPlaylists);
(DateTime Start, DateTime End) times = await this.GetTimeBounds(activityEvents, timestamp, null);
// LBP3 is grouped by actorThenObject meaning it wants all events by a user grouped together rather than
// all user events for a level or profile grouped together
List<IGrouping<ActivityGroup, ActivityDto>> groups = await activityEvents
.Where(dto => dto.Activity.Timestamp < times.Start && dto.Activity.Timestamp > times.End)
.ToActivityGroups(true)
.ToListAsync();
List<OuterActivityGroup> outerGroups = groups.ToOuterActivityGroups(true);
long oldestTimestamp = GetOldestTime(groups, times.Start).ToUnixTimeMilliseconds();
return this.Ok(GameStream.CreateFromGroups(token,
outerGroups,
times.Start.ToUnixTimeMilliseconds(),
oldestTimestamp));
}
[HttpGet]
public async Task<IActionResult> GlobalActivity
(
long timestamp,
long endTimestamp,
bool excludeNews,
bool excludeMyLevels,
bool excludeFriends,
@ -175,112 +241,109 @@ public class ActivityController : ControllerBase
{
GameTokenEntity token = this.GetToken();
if (token.GameVersion == GameVersion.LittleBigPlanet1) return this.BadRequest();
if (token.GameVersion == GameVersion.LittleBigPlanet1) return this.NotFound();
if (timestamp > TimeHelper.TimestampMillis || timestamp <= 0) timestamp = TimeHelper.TimestampMillis;
DateTime start = DateTimeExtensions.FromUnixTimeMilliseconds(timestamp);
DateTime soonestTime = await this.GetMostRecentEventTime(token, start);
Console.WriteLine(@"Most recent event occurred at " + soonestTime);
soonestTime = soonestTime.Subtract(TimeSpan.FromDays(1));
long soonestTimestamp = soonestTime.ToUnixTimeMilliseconds();
long endTimestamp = soonestTimestamp - 86_400_000;
Console.WriteLine(@$"soonestTime: {soonestTimestamp}, endTime: {endTimestamp}");
IQueryable<ActivityEntity> activityEvents = await this.GetFilters(token,
IQueryable<ActivityDto> activityEvents = await this.GetFilters(this.database.Activities.ToActivityDto(true),
token,
excludeNews,
excludeMyLevels,
excludeFriends,
excludeFavouriteUsers,
excludeMyself);
DateTime end = DateTimeExtensions.FromUnixTimeMilliseconds(endTimestamp);
(DateTime Start, DateTime End) times = await this.GetTimeBounds(activityEvents, timestamp, endTimestamp);
activityEvents = activityEvents.Where(a => a.Timestamp < start && a.Timestamp > end);
List<IGrouping<ActivityGroup, ActivityDto>> groups = await activityEvents
.Where(dto => dto.Activity.Timestamp < times.Start && dto.Activity.Timestamp > times.End)
.ToActivityGroups()
.ToListAsync();
Console.WriteLine($@"start: {start}, end: {end}");
List<OuterActivityGroup> outerGroups = groups.ToOuterActivityGroups();
List<IGrouping<ActivityGroup, ActivityEntity>> groups = await GroupActivities(activityEvents).ToListAsync();
long oldestTimestamp = GetOldestTime(groups, times.Start).ToUnixTimeMilliseconds();
foreach (IGrouping<ActivityGroup, ActivityEntity> group in groups)
await this.CacheEntities(outerGroups);
GameStream? gameStream = GameStream.CreateFromGroups(token,
outerGroups,
times.Start.ToUnixTimeMilliseconds(),
oldestTimestamp);
return this.Ok(gameStream);
}
#if DEBUG
private static void PrintOuterGroups(List<OuterActivityGroup> outerGroups)
{
foreach (OuterActivityGroup outer in outerGroups)
{
ActivityGroup key = group.Key;
Console.WriteLine(
$@"{key.GroupType}: Timestamp: {key.Timestamp}, UserId: {key.UserId}, TargetSlotId: {key.TargetSlotId}, " +
@$"TargetUserId: {key.TargetUserId}, TargetPlaylistId: {key.TargetPlaylistId}");
foreach (ActivityEntity activity in group)
Console.WriteLine(@$"Outer group key: {outer.Key}");
List<IGrouping<InnerActivityGroup, ActivityDto>> itemGroup = outer.Groups;
foreach (IGrouping<InnerActivityGroup, ActivityDto> item in itemGroup)
{
Console.WriteLine($@" {activity.Type}: Timestamp: {activity.Timestamp}");
Console.WriteLine(
@$" Inner group key: TargetId={item.Key.TargetId}, UserId={item.Key.UserId}, Type={item.Key.Type}");
foreach (ActivityDto activity in item)
{
Console.WriteLine(
@$" Activity: {activity.GroupType}, Timestamp: {activity.Activity.Timestamp}, UserId: {activity.Activity.UserId}, EventType: {activity.Activity.Type}, TargetId: {activity.TargetId}");
}
}
}
DateTime oldestTime = groups.Any() ? groups.Min(g => g.Any() ? g.Min(a => a.Timestamp) : end) : end;
long oldestTimestamp = oldestTime.ToUnixTimeMilliseconds();
return this.Ok(await GameStream.CreateFromEntityResult(this.database, token, groups, timestamp, oldestTimestamp));
}
#endif
[HttpGet("slot/{slotType}/{slotId:int}")]
public async Task<IActionResult> SlotActivity(string slotType, int slotId, long timestamp)
[HttpGet("user2/{username}")]
public async Task<IActionResult> SlotActivity(string? slotType, int slotId, string? username, long? timestamp)
{
GameTokenEntity token = this.GetToken();
if (token.GameVersion == GameVersion.LittleBigPlanet1) return this.BadRequest();
if (token.GameVersion == GameVersion.LittleBigPlanet1) return this.NotFound();
if (timestamp > TimeHelper.TimestampMillis || timestamp <= 0) timestamp = TimeHelper.TimestampMillis;
if ((SlotHelper.IsTypeInvalid(slotType) || slotId == 0) == (username == null)) return this.BadRequest();
long endTimestamp = timestamp - 864_000;
IQueryable<ActivityDto> activityQuery = this.database.Activities.ToActivityDto()
.Where(a => a.Activity.Type != EventType.NewsPost && a.Activity.Type != EventType.MMPickLevel);
if (slotType is not ("developer" or "user")) return this.BadRequest();
bool isLevelActivity = username == null;
if (slotType == "developer")
slotId = await SlotHelper.GetPlaceholderSlotId(this.database, slotId, SlotType.Developer);
// Slot activity
if (isLevelActivity)
{
if (slotType == "developer")
slotId = await SlotHelper.GetPlaceholderSlotId(this.database, slotId, SlotType.Developer);
IQueryable<ActivityDto> slotActivity = this.database.Activities.Select(ActivityToDto())
.Where(a => a.TargetSlotId == slotId);
if (!await this.database.Slots.AnyAsync(s => s.SlotId == slotId)) return this.NotFound();
DateTime start = DateTimeOffset.FromUnixTimeMilliseconds(timestamp).DateTime;
DateTime end = DateTimeOffset.FromUnixTimeMilliseconds(endTimestamp).DateTime;
activityQuery = activityQuery.Where(dto => dto.TargetSlotId == slotId);
}
// User activity
else
{
int userId = await this.database.Users.Where(u => u.Username == username)
.Select(u => u.UserId)
.FirstOrDefaultAsync();
if (userId == 0) return this.NotFound();
activityQuery = activityQuery.Where(dto => dto.Activity.UserId == userId);
}
slotActivity = slotActivity.Where(a => a.Activity.Timestamp < start && a.Activity.Timestamp > end);
(DateTime Start, DateTime End) times = await this.GetTimeBounds(activityQuery, timestamp, null);
List<IGrouping<ActivityGroup, ActivityEntity>> groups = await GroupActivities(slotActivity).ToListAsync();
activityQuery = activityQuery.Where(dto =>
dto.Activity.Timestamp < times.Start && dto.Activity.Timestamp > times.End);
DateTime oldestTime = groups.Max(g => g.Max(a => a.Timestamp));
long oldestTimestamp = new DateTimeOffset(oldestTime).ToUnixTimeMilliseconds();
List<IGrouping<ActivityGroup, ActivityDto>> groups = await activityQuery.ToActivityGroups().ToListAsync();
return this.Ok(await GameStream.CreateFromEntityResult(this.database, token, groups, timestamp, oldestTimestamp));
}
List<OuterActivityGroup> outerGroups = groups.ToOuterActivityGroups();
[HttpGet("user2/{userId:int}/")]
public async Task<IActionResult> UserActivity(int userId, long timestamp)
{
GameTokenEntity token = this.GetToken();
long oldestTimestamp = GetOldestTime(groups, times.Start).ToUnixTimeMilliseconds();
if (token.GameVersion == GameVersion.LittleBigPlanet1) return this.BadRequest();
await this.CacheEntities(outerGroups);
if (timestamp > TimeHelper.TimestampMillis || timestamp <= 0) timestamp = TimeHelper.TimestampMillis;
long endTimestamp = timestamp - 864_000;
IQueryable<ActivityDto> userActivity = this.database.Activities.Select(ActivityToDto())
.Where(a => a.TargetUserId == userId);
DateTime start = DateTimeOffset.FromUnixTimeMilliseconds(timestamp).DateTime;
DateTime end = DateTimeOffset.FromUnixTimeMilliseconds(endTimestamp).DateTime;
userActivity = userActivity.Where(a => a.Activity.Timestamp < start && a.Activity.Timestamp > end);
List<IGrouping<ActivityGroup, ActivityEntity>> groups = await GroupActivities(userActivity).ToListAsync();
DateTime oldestTime = groups.Max(g => g.Max(a => a.Timestamp));
long oldestTimestamp = new DateTimeOffset(oldestTime).ToUnixTimeMilliseconds();
return this.Ok(
await GameStream.CreateFromEntityResult(this.database, token, groups, timestamp, oldestTimestamp));
return this.Ok(GameStream.CreateFromGroups(token,
outerGroups,
times.Start.ToUnixTimeMilliseconds(),
oldestTimestamp));
}
}

View file

@ -0,0 +1,31 @@
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Types.Entities.Website;
using LBPUnion.ProjectLighthouse.Types.Serialization.News;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Controllers;
[ApiController]
[Authorize]
[Route("LITTLEBIGPLANETPS3_XML/")]
[Produces("text/xml")]
public class NewsController : ControllerBase
{
private readonly DatabaseContext database;
public NewsController(DatabaseContext database)
{
this.database = database;
}
[HttpGet("news")]
public async Task<IActionResult> GetNews()
{
List<WebsiteAnnouncementEntity> websiteAnnouncements =
await this.database.WebsiteAnnouncements.OrderByDescending(a => a.AnnouncementId).ToListAsync();
return this.Ok(GameNews.CreateFromEntity(websiteAnnouncements));
}
}

View file

@ -141,6 +141,22 @@ public class ReviewController : ControllerBase
return this.Ok();
}
[HttpGet("review/user/{slotId:int}/{reviewerName}")]
public async Task<IActionResult> GetReview(int slotId, string reviewerName)
{
GameTokenEntity token = this.GetToken();
int reviewerId = await this.database.Users.Where(u => u.Username == reviewerName)
.Select(s => s.UserId)
.FirstOrDefaultAsync();
if (reviewerId == 0) return this.NotFound();
ReviewEntity? review = await this.database.Reviews.FirstOrDefaultAsync(r => r.ReviewerId == reviewerId && r.SlotId == slotId);
if (review == null) return this.NotFound();
return this.Ok(GameReview.CreateFromEntity(review, token));
}
[HttpGet("reviewsFor/user/{slotId:int}")]
public async Task<IActionResult> ReviewsFor(int slotId)
{

View file

@ -2,7 +2,7 @@
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Filter;
using LBPUnion.ProjectLighthouse.Filter.Filters;
using LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
using LBPUnion.ProjectLighthouse.Filter.Sorts;
using LBPUnion.ProjectLighthouse.Servers.GameServer.Extensions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;

View file

@ -3,7 +3,7 @@ using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Filter;
using LBPUnion.ProjectLighthouse.Filter.Filters;
using LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
using LBPUnion.ProjectLighthouse.Filter.Sorts;
using LBPUnion.ProjectLighthouse.Filter.Sorts.Metadata;
using LBPUnion.ProjectLighthouse.Helpers;

View file

@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Filter;
using LBPUnion.ProjectLighthouse.Filter.Filters;
using LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
using LBPUnion.ProjectLighthouse.Servers.GameServer.Extensions;
using LBPUnion.ProjectLighthouse.Types.Serialization.Slot;

View file

@ -1,5 +1,5 @@
using LBPUnion.ProjectLighthouse.Filter;
using LBPUnion.ProjectLighthouse.Filter.Filters;
using LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
using LBPUnion.ProjectLighthouse.Types.Entities.Token;
using LBPUnion.ProjectLighthouse.Types.Levels;
using LBPUnion.ProjectLighthouse.Types.Users;

View file

@ -1,7 +1,7 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Filter;
using LBPUnion.ProjectLighthouse.Filter.Filters;
using LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Entities.Token;

View file

@ -2,7 +2,7 @@
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Filter;
using LBPUnion.ProjectLighthouse.Filter.Filters;
using LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
using LBPUnion.ProjectLighthouse.Filter.Sorts;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Entities.Token;

View file

@ -0,0 +1,727 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Tests.Helpers;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Interaction;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using Xunit;
namespace ProjectLighthouse.Tests.GameApiTests.Unit.Activity;
[Trait("Category", "Unit")]
public class ActivityEventHandlerTests
{
#region Entity Inserts
[Fact]
public async Task Level_Insert_ShouldCreatePublishActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity slot = new()
{
CreatorId = 1,
SlotId = 1,
};
database.Slots.Add(slot);
await database.SaveChangesAsync();
eventHandler.OnEntityInserted(database, slot);
Assert.NotNull(database.Activities.OfType<LevelActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.PublishLevel && a.SlotId == 1));
}
[Fact]
public async Task LevelComment_Insert_ShouldCreateCommentActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
CommentEntity comment = new()
{
CommentId = 1,
PosterUserId = 1,
TargetId = 1,
Type = CommentType.Level,
};
database.Comments.Add(comment);
await database.SaveChangesAsync();
eventHandler.OnEntityInserted(database, comment);
Assert.NotNull(database.Activities.OfType<CommentActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.CommentOnLevel && a.CommentId == 1));
}
[Fact]
public async Task ProfileComment_Insert_ShouldCreateCommentActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
CommentEntity comment = new()
{
CommentId = 1,
PosterUserId = 1,
TargetId = 1,
Type = CommentType.Profile,
};
database.Comments.Add(comment);
await database.SaveChangesAsync();
eventHandler.OnEntityInserted(database, comment);
Assert.NotNull(database.Activities.OfType<CommentActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.CommentOnUser && a.CommentId == 1));
}
[Fact]
public async Task Photo_Insert_ShouldCreatePhotoActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
PhotoEntity photo = new()
{
PhotoId = 1,
CreatorId = 1,
};
database.Photos.Add(photo);
await database.SaveChangesAsync();
eventHandler.OnEntityInserted(database, photo);
Assert.NotNull(database.Activities.OfType<PhotoActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.UploadPhoto && a.PhotoId == 1));
}
[Fact]
public async Task Score_Insert_ShouldCreateScoreActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity slot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(slot);
ScoreEntity score = new()
{
ScoreId = 1,
SlotId = 1,
PlayerIdCollection = "test",
};
database.Scores.Add(score);
await database.SaveChangesAsync();
eventHandler.OnEntityInserted(database, score);
Assert.NotNull(database.Activities.OfType<ScoreActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.Score && a.ScoreId == 1));
}
[Fact]
public async Task HeartedLevel_Insert_ShouldCreateLevelActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity slot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(slot);
await database.SaveChangesAsync();
HeartedLevelEntity heartedLevel = new()
{
HeartedLevelId = 1,
UserId = 1,
SlotId = 1,
};
eventHandler.OnEntityInserted(database, heartedLevel);
Assert.NotNull(database.Activities.OfType<LevelActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.HeartLevel && a.SlotId == 1));
}
[Fact]
public async Task HeartedProfile_Insert_ShouldCreateUserActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
HeartedProfileEntity heartedProfile = new()
{
HeartedProfileId = 1,
UserId = 1,
HeartedUserId = 1,
};
eventHandler.OnEntityInserted(database, heartedProfile);
Assert.NotNull(database.Activities.OfType<UserActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.HeartUser && a.TargetUserId == 1));
}
[Fact]
public async Task HeartedPlaylist_Insert_ShouldCreatePlaylistActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
PlaylistEntity playlist = new()
{
PlaylistId = 1,
CreatorId = 1,
};
database.Playlists.Add(playlist);
await database.SaveChangesAsync();
HeartedPlaylistEntity heartedPlaylist = new()
{
HeartedPlaylistId = 1,
UserId = 1,
PlaylistId = 1,
};
eventHandler.OnEntityInserted(database, heartedPlaylist);
Assert.NotNull(database.Activities.OfType<PlaylistActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.HeartPlaylist && a.PlaylistId == 1));
}
[Fact]
public async Task VisitedLevel_Insert_ShouldCreateLevelActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity slot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(slot);
await database.SaveChangesAsync();
VisitedLevelEntity visitedLevel = new()
{
VisitedLevelId = 1,
UserId = 1,
SlotId = 1,
};
eventHandler.OnEntityInserted(database, visitedLevel);
Assert.NotNull(database.Activities.OfType<LevelActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.PlayLevel && a.SlotId == 1));
}
[Fact]
public async Task Review_Insert_ShouldCreateReviewActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity slot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(slot);
ReviewEntity review = new()
{
ReviewId = 1,
ReviewerId = 1,
SlotId = 1,
};
database.Reviews.Add(review);
await database.SaveChangesAsync();
eventHandler.OnEntityInserted(database, review);
Assert.NotNull(database.Activities.OfType<ReviewActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.ReviewLevel && a.ReviewId == 1));
}
[Fact]
public async Task RatedLevel_WithRatingInsert_ShouldCreateLevelActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity slot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(slot);
await database.SaveChangesAsync();
RatedLevelEntity ratedLevel = new()
{
RatedLevelId = 1,
UserId = 1,
SlotId = 1,
Rating = 1,
};
eventHandler.OnEntityInserted(database, ratedLevel);
Assert.NotNull(database.Activities.OfType<LevelActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.DpadRateLevel && a.SlotId == 1));
}
[Fact]
public async Task RatedLevel_WithLBP1RatingInsert_ShouldCreateLevelActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity slot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(slot);
await database.SaveChangesAsync();
RatedLevelEntity ratedLevel = new()
{
RatedLevelId = 1,
UserId = 1,
SlotId = 1,
RatingLBP1 = 5,
};
eventHandler.OnEntityInserted(database, ratedLevel);
Assert.NotNull(database.Activities.OfType<LevelActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.RateLevel && a.SlotId == 1));
}
[Fact]
public async Task Playlist_Insert_ShouldCreateLevelActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
PlaylistEntity playlist = new()
{
PlaylistId = 1,
CreatorId = 1,
};
database.Playlists.Add(playlist);
await database.SaveChangesAsync();
eventHandler.OnEntityInserted(database, playlist);
Assert.NotNull(database.Activities.OfType<PlaylistActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.CreatePlaylist && a.PlaylistId == 1));
}
#endregion
#region Entity changes
[Fact]
public async Task VisitedLevel_WithNoChange_ShouldNotCreateLevelActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity slot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(slot);
await database.SaveChangesAsync();
VisitedLevelEntity visitedLevel = new()
{
VisitedLevelId = 1,
UserId = 1,
SlotId = 1,
};
eventHandler.OnEntityChanged(database, visitedLevel, visitedLevel);
Assert.Null(database.Activities.OfType<LevelActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.PlayLevel && a.SlotId == 1));
}
[Fact]
public async Task VisitedLevel_WithChange_ShouldCreateLevelActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity slot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(slot);
await database.SaveChangesAsync();
VisitedLevelEntity oldVisitedLevel = new()
{
VisitedLevelId = 1,
UserId = 1,
SlotId = 1,
PlaysLBP2 = 1,
};
VisitedLevelEntity newVisitedLevel = new()
{
VisitedLevelId = 1,
UserId = 1,
SlotId = 1,
PlaysLBP2 = 2,
};
eventHandler.OnEntityChanged(database, oldVisitedLevel, newVisitedLevel);
Assert.NotNull(database.Activities.OfType<LevelActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.PlayLevel && a.SlotId == 1));
}
[Fact]
public async Task Slot_WithTeamPickChange_ShouldCreateLevelActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity oldSlot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(oldSlot);
await database.SaveChangesAsync();
SlotEntity newSlot = new()
{
SlotId = 1,
CreatorId = 1,
TeamPick = true,
};
eventHandler.OnEntityChanged(database, oldSlot, newSlot);
Assert.NotNull(database.Activities.OfType<LevelActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.MMPickLevel && a.SlotId == 1));
}
[Fact]
public async Task Slot_WithRepublish_ShouldCreateLevelActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity oldSlot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(oldSlot);
await database.SaveChangesAsync();
SlotEntity newSlot = new()
{
SlotId = 1,
CreatorId = 1,
LastUpdated = 1,
};
eventHandler.OnEntityChanged(database, oldSlot, newSlot);
Assert.NotNull(database.Activities.OfType<LevelActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.PublishLevel && a.SlotId == 1));
}
[Fact]
public async Task Comment_WithDeletion_ShouldCreateCommentActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
CommentEntity oldComment = new()
{
CommentId = 1,
PosterUserId = 1,
Type = CommentType.Level,
};
database.Comments.Add(oldComment);
await database.SaveChangesAsync();
CommentEntity newComment = new()
{
CommentId = 1,
PosterUserId = 1,
Type = CommentType.Level,
Deleted = true,
};
eventHandler.OnEntityChanged(database, oldComment, newComment);
Assert.NotNull(database.Activities.OfType<CommentActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.DeleteLevelComment && a.CommentId == 1));
}
[Fact]
public async Task Playlist_WithSlotsChanged_ShouldCreatePlaylistWithSlotActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity slot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(slot);
PlaylistEntity oldPlaylist = new()
{
PlaylistId = 1,
CreatorId = 1,
};
database.Playlists.Add(oldPlaylist);
await database.SaveChangesAsync();
PlaylistEntity newPlaylist = new()
{
PlaylistId = 1,
CreatorId = 1,
SlotCollection = "1",
};
eventHandler.OnEntityChanged(database, oldPlaylist, newPlaylist);
Assert.NotNull(database.Activities.OfType<PlaylistWithSlotActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.AddLevelToPlaylist && a.PlaylistId == 1 && a.SlotId == 1));
}
#endregion
#region Entity deletion
[Fact]
public async Task HeartedLevel_Delete_ShouldCreateLevelActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity slot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(slot);
HeartedLevelEntity heartedLevel = new()
{
HeartedLevelId = 1,
UserId = 1,
SlotId = 1,
};
database.HeartedLevels.Add(heartedLevel);
await database.SaveChangesAsync();
eventHandler.OnEntityDeleted(database, heartedLevel);
Assert.NotNull(database.Activities.OfType<LevelActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.UnheartLevel && a.SlotId == 1));
}
[Fact]
public async Task HeartedProfile_Delete_ShouldCreateLevelActivity()
{
ActivityEntityEventHandler eventHandler = new();
DatabaseContext database = await MockHelper.GetTestDatabase(new List<UserEntity>
{
new()
{
Username = "test",
UserId = 1,
},
});
SlotEntity slot = new()
{
SlotId = 1,
CreatorId = 1,
};
database.Slots.Add(slot);
HeartedProfileEntity heartedProfile = new()
{
HeartedProfileId = 1,
UserId = 1,
HeartedUserId = 1,
};
database.HeartedProfiles.Add(heartedProfile);
await database.SaveChangesAsync();
eventHandler.OnEntityDeleted(database, heartedProfile);
Assert.NotNull(database.Activities.OfType<UserActivityEntity>()
.FirstOrDefault(a => a.Type == EventType.UnheartUser && a.UserId == 1));
}
#endregion
}

View file

@ -0,0 +1,6 @@
namespace ProjectLighthouse.Tests.GameApiTests.Unit.Controllers;
public class ActivityControllerTests
{
//TODO write activity controller tests
}

View file

@ -1,5 +1,5 @@
using LBPUnion.ProjectLighthouse.Filter;
using LBPUnion.ProjectLighthouse.Filter.Filters;
using LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
using LBPUnion.ProjectLighthouse.Servers.GameServer.Controllers.Slots;
using LBPUnion.ProjectLighthouse.Servers.GameServer.Extensions;
using LBPUnion.ProjectLighthouse.Tests.Helpers;

View file

@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using LBPUnion.ProjectLighthouse.Filter;
using LBPUnion.ProjectLighthouse.Filter.Filters;
using LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Levels;

View file

@ -1,3 +1,4 @@
using System;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
@ -90,30 +91,34 @@ public partial class DatabaseContext : DbContext
public static DatabaseContext CreateNewInstance()
{
DbContextOptionsBuilder<DatabaseContext> builder = new();
builder.UseMySql(ServerConfiguration.Instance.DbConnectionString,
MySqlServerVersion.LatestSupportedServerVersion);
ConfigureBuilder()(builder);
return new DatabaseContext(builder.Options);
}
public static Action<DbContextOptionsBuilder> ConfigureBuilder()
{
return builder =>
{
builder.UseMySql(ServerConfiguration.Instance.DbConnectionString,
MySqlServerVersion.LatestSupportedServerVersion);
builder.AddInterceptors(new ActivityInterceptor(new ActivityEntityEventHandler()));
};
}
#region Activity
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
//TODO implement reviews
modelBuilder.Entity<LevelActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<PhotoActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<PlaylistActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<PlaylistWithSlotActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<ScoreActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<UserActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<NewsActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<CommentActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<UserActivityEntity>().UseTphMappingStrategy();
modelBuilder.Entity<ReviewActivityEntity>().UseTphMappingStrategy();
base.OnModelCreating(modelBuilder);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(new ActivityInterceptor(new ActivityEntityEventHandler()));
base.OnConfiguring(optionsBuilder);
}
#endregion
}

View file

@ -0,0 +1,121 @@
using System.Collections.Generic;
using System.Linq;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
namespace LBPUnion.ProjectLighthouse.Extensions;
public static class ActivityQueryExtensions
{
public static List<int> GetIds(this IReadOnlyCollection<OuterActivityGroup> groups, ActivityGroupType type)
{
List<int> ids = new();
// Add outer group ids
ids.AddRange(groups.Where(g => g.Key.GroupType == type)
.Where(g => g.Key.TargetId != 0)
.Select(g => g.Key.TargetId)
.ToList());
// Add specific event ids
ids.AddRange(groups.SelectMany(g =>
g.Groups.SelectMany(gr => gr.Where(a => a.GroupType == type).Select(a => a.TargetId))));
if (type == ActivityGroupType.User)
{
ids.AddRange(groups.Where(g => g.Key.GroupType is not ActivityGroupType.News)
.SelectMany(g => g.Groups.Select(a => a.Key.UserId)));
}
return ids.Distinct().ToList();
}
public static IQueryable<IGrouping<ActivityGroup, ActivityDto>> ToActivityGroups
(this IQueryable<ActivityDto> activityQuery, bool groupByActor = false) =>
groupByActor
? activityQuery.GroupBy(dto => new ActivityGroup
{
Timestamp = dto.Activity.Timestamp.Date,
UserId = dto.Activity.UserId,
TargetNewsId = dto.TargetNewsId ?? 0,
TargetTeamPickSlotId = dto.TargetTeamPickId ?? 0,
})
: activityQuery.GroupBy(dto => new ActivityGroup
{
Timestamp = dto.Activity.Timestamp.Date,
TargetUserId = dto.TargetUserId ?? 0,
TargetSlotId = dto.TargetSlotId ?? 0,
TargetPlaylistId = dto.TargetPlaylistId ?? 0,
TargetNewsId = dto.TargetNewsId ?? 0,
});
public static List<OuterActivityGroup> ToOuterActivityGroups
(this IEnumerable<IGrouping<ActivityGroup, ActivityDto>> activityGroups, bool groupByActor = false) =>
// Pin news posts to the top
activityGroups.OrderByDescending(g => g.Key.GroupType == ActivityGroupType.News ? 1 : 0)
.ThenByDescending(g => g.MaxBy(a => a.Activity.Timestamp)?.Activity.Timestamp ?? g.Key.Timestamp)
.Select(g => new OuterActivityGroup
{
Key = g.Key,
Groups = g.OrderByDescending(a => a.Activity.Timestamp)
.GroupBy(gr => new InnerActivityGroup
{
Type = groupByActor ? gr.GroupType : gr.GroupType != ActivityGroupType.News ? ActivityGroupType.User : ActivityGroupType.News,
UserId = gr.Activity.UserId,
TargetId = groupByActor ? gr.TargetId : gr.Activity.UserId,
})
.ToList(),
})
.ToList();
// WARNING - To the next person who tries to improve this code: As of writing this, it's not possible
// to build a pattern matching switch statement with expression trees. so the only other option
// is to basically rewrite this nested ternary mess with expression trees which isn't much better
// The resulting SQL generated by EntityFramework uses a CASE statement which is probably fine
public static IQueryable<ActivityDto> ToActivityDto
(this IQueryable<ActivityEntity> activityQuery, bool includeSlotCreator = false, bool includeTeamPick = false)
{
return activityQuery.Select(a => new ActivityDto
{
Activity = a,
TargetSlotId = a is LevelActivityEntity
? ((LevelActivityEntity)a).SlotId
: a is PhotoActivityEntity && ((PhotoActivityEntity)a).Photo.PhotoId != 0
? ((PhotoActivityEntity)a).Photo.SlotId
: a is CommentActivityEntity && ((CommentActivityEntity)a).Comment.Type == CommentType.Level
? ((CommentActivityEntity)a).Comment.TargetId
: a is ScoreActivityEntity
? ((ScoreActivityEntity)a).Score.SlotId
: a is ReviewActivityEntity
? ((ReviewActivityEntity)a).Review.SlotId
: 0,
TargetUserId = a is UserActivityEntity
? ((UserActivityEntity)a).TargetUserId
: a is CommentActivityEntity && ((CommentActivityEntity)a).Comment.Type == CommentType.Profile
? ((CommentActivityEntity)a).Comment.TargetId
: a is PhotoActivityEntity && ((PhotoActivityEntity)a).Photo.SlotId != 0
? ((PhotoActivityEntity)a).Photo.CreatorId
: 0,
TargetPlaylistId = a is PlaylistActivityEntity || a is PlaylistWithSlotActivityEntity
? ((PlaylistActivityEntity)a).PlaylistId
: 0,
TargetNewsId = a is NewsActivityEntity ? ((NewsActivityEntity)a).NewsId : 0,
TargetTeamPickId = includeTeamPick
? a.Type == EventType.MMPickLevel && a is LevelActivityEntity ? ((LevelActivityEntity)a).SlotId : 0
: 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.TargetId
: a is ScoreActivityEntity
? ((ScoreActivityEntity)a).Score.Slot.CreatorId
: a is ReviewActivityEntity
? ((ReviewActivityEntity)a).Review.Slot!.CreatorId
: 0
: 0,
});
}
}

View file

@ -44,6 +44,7 @@ public static partial class ControllerExtensions
public static async Task<T?> DeserializeBody<T>(this ControllerBase controller, params string[] rootElements)
{
string bodyString = await controller.ReadBodyAsync();
if (bodyString.Length == 0) return default;
try
{
// Prevent unescaped ampersands from causing deserialization to fail

View file

@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter;
public class ActivityQueryBuilder : IQueryBuilder<ActivityDto>
{
private readonly List<IActivityFilter> filters;
public ActivityQueryBuilder()
{
this.filters = new List<IActivityFilter>();
}
public Expression<Func<ActivityDto, bool>> Build()
{
Expression<Func<ActivityDto, bool>> predicate = PredicateExtensions.True<ActivityDto>();
predicate = this.filters.Aggregate(predicate, (current, filter) => current.And(filter.GetPredicate()));
return predicate;
}
public ActivityQueryBuilder RemoveFilter(Type type)
{
this.filters.RemoveAll(f => f.GetType() == type);
return this;
}
#nullable enable
public IEnumerable<IActivityFilter> GetFilters(Type type) => this.filters.Where(f => f.GetType() == type).ToList();
#nullable disable
public ActivityQueryBuilder AddFilter(int index, IActivityFilter filter)
{
this.filters.Insert(index, filter);
return this;
}
public ActivityQueryBuilder Clone()
{
ActivityQueryBuilder clone = new();
clone.filters.AddRange(this.filters);
return clone;
}
public ActivityQueryBuilder AddFilter(IActivityFilter filter)
{
this.filters.Add(filter);
return this;
}
}

View file

@ -0,0 +1,27 @@
#nullable enable
using System;
using System.Linq;
using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Activity;
public class EventTypeFilter : IActivityFilter
{
private readonly EventType[] events;
public EventTypeFilter(params EventType[] events)
{
this.events = events;
}
public Expression<Func<ActivityDto, bool>> GetPredicate()
{
Expression<Func<ActivityDto, bool>> predicate = PredicateExtensions.False<ActivityDto>();
predicate = this.events.Aggregate(predicate,
(current, eventType) => current.Or(a => a.Activity.Type == eventType));
return predicate;
}
}

View file

@ -0,0 +1,12 @@
using System;
using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Activity;
public class ExcludeNewsFilter : IActivityFilter
{
public Expression<Func<ActivityDto, bool>> GetPredicate() => a => a.Activity is NewsActivityEntity && a.Activity.Type != EventType.NewsPost;
}

View file

@ -0,0 +1,14 @@
using System;
using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Activity;
public class IncludeNewsFilter : IActivityFilter
{
public Expression<Func<ActivityDto, bool>> GetPredicate() =>
a => (a.Activity is NewsActivityEntity && a.Activity.Type == EventType.NewsPost) ||
a.Activity.Type == EventType.MMPickLevel;
}

View file

@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Activity;
public class IncludeUserIdFilter : IActivityFilter
{
private readonly IEnumerable<int> userIds;
private readonly EventTypeFilter eventFilter;
public IncludeUserIdFilter(IEnumerable<int> userIds, EventTypeFilter eventFilter = null)
{
this.userIds = userIds;
this.eventFilter = eventFilter;
}
public Expression<Func<ActivityDto, bool>> GetPredicate()
{
Expression<Func<ActivityDto, bool>> predicate = PredicateExtensions.False<ActivityDto>();
predicate = this.userIds.Aggregate(predicate, (current, friendId) => current.Or(a => a.Activity.UserId == friendId));
if (this.eventFilter != null) predicate = predicate.And(this.eventFilter.GetPredicate());
return predicate;
}
}

View file

@ -0,0 +1,27 @@
using System;
using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Activity;
public class MyLevelActivityFilter : IActivityFilter
{
private readonly int userId;
private readonly EventTypeFilter eventFilter;
public MyLevelActivityFilter(int userId, EventTypeFilter eventFilter = null)
{
this.userId = userId;
this.eventFilter = eventFilter;
}
public Expression<Func<ActivityDto, bool>> GetPredicate()
{
Expression<Func<ActivityDto, bool>> predicate = PredicateExtensions.False<ActivityDto>();
predicate = predicate.Or(a => a.TargetSlotCreatorId == this.userId);
if (this.eventFilter != null) predicate = predicate.And(this.eventFilter.GetPredicate());
return predicate;
}
}

View file

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Activity;
public class PlaylistActivityFilter : IActivityFilter
{
private readonly List<int> playlistIds;
private readonly EventTypeFilter eventFilter;
public PlaylistActivityFilter(List<int> playlistIds, EventTypeFilter eventFilter = null)
{
this.playlistIds = playlistIds;
this.eventFilter = eventFilter;
}
public Expression<Func<ActivityDto, bool>> GetPredicate()
{
Expression<Func<ActivityDto, bool>> predicate = PredicateExtensions.False<ActivityDto>();
predicate = this.playlistIds.Aggregate(predicate, (current, playlistId) => current.Or(a => (a.Activity is PlaylistActivityEntity || a.Activity is PlaylistWithSlotActivityEntity) && a.TargetPlaylistId == playlistId));
if (this.eventFilter != null) predicate = predicate.And(this.eventFilter.GetPredicate());
return predicate;
}
}

View file

@ -3,7 +3,7 @@ using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class AdventureFilter : ISlotFilter
{

View file

@ -5,7 +5,7 @@ using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class AuthorLabelFilter : ISlotFilter
{
@ -20,7 +20,7 @@ public class AuthorLabelFilter : ISlotFilter
{
Expression<Func<SlotEntity, bool>> predicate = PredicateExtensions.True<SlotEntity>();
predicate = this.labels.Aggregate(predicate,
(current, label) => current.And(s => s.AuthorLabels.Contains(label)));
(current, label) => PredicateExtensions.And<SlotEntity>(current, s => s.AuthorLabels.Contains(label)));
return predicate;
}
}

View file

@ -3,7 +3,7 @@ using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class CreatorFilter : ISlotFilter
{

View file

@ -3,7 +3,7 @@ using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class CrossControlFilter : ISlotFilter
{

View file

@ -3,7 +3,7 @@ using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class ExcludeAdventureFilter : ISlotFilter
{

View file

@ -3,7 +3,7 @@ using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class ExcludeCrossControlFilter : ISlotFilter
{

View file

@ -4,7 +4,7 @@ using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
using LBPUnion.ProjectLighthouse.Types.Users;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class ExcludeLBP1OnlyFilter : ISlotFilter
{

View file

@ -3,7 +3,7 @@ using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class ExcludeMovePackFilter : ISlotFilter
{

View file

@ -4,7 +4,7 @@ using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class FirstUploadedFilter : ISlotFilter
{

View file

@ -4,7 +4,7 @@ using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
using LBPUnion.ProjectLighthouse.Types.Users;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class GameVersionFilter : ISlotFilter
{

View file

@ -6,7 +6,7 @@ using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
using LBPUnion.ProjectLighthouse.Types.Users;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class GameVersionListFilter : ISlotFilter
{
@ -19,5 +19,5 @@ public class GameVersionListFilter : ISlotFilter
public Expression<Func<SlotEntity, bool>> GetPredicate() =>
this.versions.Aggregate(PredicateExtensions.False<SlotEntity>(),
(current, version) => current.Or(s => s.GameVersion == version));
(current, version) => PredicateExtensions.Or<SlotEntity>(current, s => s.GameVersion == version));
}

View file

@ -3,7 +3,7 @@ using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class HiddenSlotFilter : ISlotFilter
{

View file

@ -3,7 +3,7 @@ using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class MovePackFilter : ISlotFilter
{

View file

@ -4,7 +4,7 @@ using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class PlayerCountFilter : ISlotFilter
{

View file

@ -4,7 +4,7 @@ using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class ResultTypeFilter : ISlotFilter
{

View file

@ -6,7 +6,7 @@ using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class SlotIdFilter : ISlotFilter
{
@ -20,7 +20,7 @@ public class SlotIdFilter : ISlotFilter
public Expression<Func<SlotEntity, bool>> GetPredicate()
{
Expression<Func<SlotEntity, bool>> predicate = PredicateExtensions.False<SlotEntity>();
predicate = this.slotIds.Aggregate(predicate, (current, slotId) => current.Or(s => s.SlotId == slotId));
predicate = this.slotIds.Aggregate(predicate, (current, slotId) => PredicateExtensions.Or<SlotEntity>(current, s => s.SlotId == slotId));
return predicate;
}
}

View file

@ -4,7 +4,7 @@ using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
using LBPUnion.ProjectLighthouse.Types.Levels;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class SlotTypeFilter : ISlotFilter
{

View file

@ -3,7 +3,7 @@ using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class SubLevelFilter : ISlotFilter
{

View file

@ -3,7 +3,7 @@ using System.Linq.Expressions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class TeamPickFilter : ISlotFilter
{

View file

@ -4,7 +4,7 @@ using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Filter;
namespace LBPUnion.ProjectLighthouse.Filter.Filters;
namespace LBPUnion.ProjectLighthouse.Filter.Filters.Slot;
public class TextFilter : ISlotFilter
{

View file

@ -0,0 +1,149 @@
using System;
using LBPUnion.ProjectLighthouse.Database;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ProjectLighthouse.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20230725013522_InitialActivity")]
public partial class InitialActivity : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Activities",
columns: table => new
{
ActivityId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Timestamp = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UserId = table.Column<int>(type: "int", nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Discriminator = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CommentId = table.Column<int>(type: "int", nullable: true),
SlotId = table.Column<int>(type: "int", nullable: true),
NewsId = table.Column<int>(type: "int", nullable: true),
PhotoId = table.Column<int>(type: "int", nullable: true),
PlaylistId = table.Column<int>(type: "int", nullable: true),
ReviewId = table.Column<int>(type: "int", nullable: true),
ScoreId = table.Column<int>(type: "int", nullable: true),
TargetUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Activities", x => x.ActivityId);
table.ForeignKey(
name: "FK_Activities_Comments_CommentId",
column: x => x.CommentId,
principalTable: "Comments",
principalColumn: "CommentId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Activities_Photos_PhotoId",
column: x => x.PhotoId,
principalTable: "Photos",
principalColumn: "PhotoId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Activities_Playlists_PlaylistId",
column: x => x.PlaylistId,
principalTable: "Playlists",
principalColumn: "PlaylistId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Activities_Reviews_ReviewId",
column: x => x.ReviewId,
principalTable: "Reviews",
principalColumn: "ReviewId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Activities_Scores_ScoreId",
column: x => x.ScoreId,
principalTable: "Scores",
principalColumn: "ScoreId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Activities_Slots_SlotId",
column: x => x.SlotId,
principalTable: "Slots",
principalColumn: "SlotId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Activities_Users_TargetUserId",
column: x => x.TargetUserId,
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Activities_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Activities_WebsiteAnnouncements_NewsId",
column: x => x.NewsId,
principalTable: "WebsiteAnnouncements",
principalColumn: "AnnouncementId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Activities_CommentId",
table: "Activities",
column: "CommentId");
migrationBuilder.CreateIndex(
name: "IX_Activities_NewsId",
table: "Activities",
column: "NewsId");
migrationBuilder.CreateIndex(
name: "IX_Activities_PhotoId",
table: "Activities",
column: "PhotoId");
migrationBuilder.CreateIndex(
name: "IX_Activities_PlaylistId",
table: "Activities",
column: "PlaylistId");
migrationBuilder.CreateIndex(
name: "IX_Activities_ReviewId",
table: "Activities",
column: "ReviewId");
migrationBuilder.CreateIndex(
name: "IX_Activities_ScoreId",
table: "Activities",
column: "ScoreId");
migrationBuilder.CreateIndex(
name: "IX_Activities_SlotId",
table: "Activities",
column: "SlotId");
migrationBuilder.CreateIndex(
name: "IX_Activities_TargetUserId",
table: "Activities",
column: "TargetUserId");
migrationBuilder.CreateIndex(
name: "IX_Activities_UserId",
table: "Activities",
column: "UserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Activities");
}
}
}

View file

@ -19,6 +19,36 @@ namespace ProjectLighthouse.Migrations
.HasAnnotation("ProductVersion", "8.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity", b =>
{
b.Property<int>("ActivityId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Discriminator")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("Timestamp")
.HasColumnType("datetime(6)");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("ActivityId");
b.HasIndex("UserId");
b.ToTable("Activities");
b.HasDiscriminator<string>("Discriminator").HasValue("ActivityEntity");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedLevelEntity", b =>
{
b.Property<int>("HeartedLevelId")
@ -1090,6 +1120,136 @@ namespace ProjectLighthouse.Migrations
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 =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("SlotId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int")
.HasColumnName("SlotId");
b.HasIndex("SlotId");
b.HasDiscriminator().HasValue("LevelActivityEntity");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.NewsActivityEntity", b =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("NewsId")
.HasColumnType("int");
b.HasIndex("NewsId");
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 =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("PlaylistId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int")
.HasColumnName("PlaylistId");
b.HasIndex("PlaylistId");
b.HasDiscriminator().HasValue("PlaylistActivityEntity");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.PlaylistWithSlotActivityEntity", b =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("PlaylistId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int")
.HasColumnName("PlaylistId");
b.Property<int>("SlotId")
.ValueGeneratedOnUpdateSometimes()
.HasColumnType("int")
.HasColumnName("SlotId");
b.HasIndex("PlaylistId");
b.HasDiscriminator().HasValue("PlaylistWithSlotActivityEntity");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ReviewActivityEntity", b =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("ReviewId")
.HasColumnType("int");
b.HasIndex("ReviewId");
b.HasDiscriminator().HasValue("ReviewActivityEntity");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ScoreActivityEntity", b =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("ScoreId")
.HasColumnType("int");
b.HasIndex("ScoreId");
b.HasDiscriminator().HasValue("ScoreActivityEntity");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.UserActivityEntity", b =>
{
b.HasBaseType("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity");
b.Property<int>("TargetUserId")
.HasColumnType("int");
b.HasIndex("TargetUserId");
b.HasDiscriminator().HasValue("UserActivityEntity");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ActivityEntity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Interaction.HeartedLevelEntity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot")
@ -1483,6 +1643,105 @@ namespace ProjectLighthouse.Migrations
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 =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.SlotEntity", "Slot")
.WithMany()
.HasForeignKey("SlotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Slot");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.NewsActivityEntity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Website.WebsiteAnnouncementEntity", "News")
.WithMany()
.HasForeignKey("NewsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
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 =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.PlaylistEntity", "Playlist")
.WithMany()
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Playlist");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.PlaylistWithSlotActivityEntity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.PlaylistEntity", "Playlist")
.WithMany()
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Playlist");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ReviewActivityEntity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.ReviewEntity", "Review")
.WithMany()
.HasForeignKey("ReviewId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Review");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.ScoreActivityEntity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Level.ScoreEntity", "Score")
.WithMany()
.HasForeignKey("ScoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Score");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Activity.UserActivityEntity", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Types.Entities.Profile.UserEntity", "TargetUser")
.WithMany()
.HasForeignKey("TargetUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("TargetUser");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Types.Entities.Profile.PhotoEntity", b =>
{
b.Navigation("PhotoSubjects");

View file

@ -0,0 +1,33 @@
using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
namespace LBPUnion.ProjectLighthouse.Types.Activity;
public class ActivityDto
{
public required ActivityEntity Activity { get; set; }
public int? TargetSlotId { get; set; }
public int? TargetSlotCreatorId { get; set; }
public int? TargetUserId { get; set; }
public int? TargetPlaylistId { get; set; }
public int? TargetNewsId { get; set; }
public int? TargetTeamPickId { get; set; }
public int TargetId =>
this.GroupType switch
{
ActivityGroupType.User => this.TargetUserId ?? 0,
ActivityGroupType.Level => this.TargetSlotId ?? 0,
ActivityGroupType.Playlist => this.TargetPlaylistId ?? 0,
ActivityGroupType.News => this.TargetNewsId ?? 0,
_ => this.Activity.UserId,
};
public ActivityGroupType GroupType =>
this.TargetSlotId != 0
? ActivityGroupType.Level
: this.TargetUserId != 0
? ActivityGroupType.User
: this.TargetPlaylistId != 0
? ActivityGroupType.Playlist
: ActivityGroupType.News;
}

View file

@ -4,12 +4,13 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Reflection;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Interaction;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Entities.Website;
using LBPUnion.ProjectLighthouse.Types.Levels;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Types.Activity;
@ -44,7 +45,7 @@ public class ActivityEntityEventHandler : IEntityEventHandler
Type = EventType.Score,
ScoreId = score.ScoreId,
//TODO merge score migration
// UserId = int.Parse(score.PlayerIds[0]),
UserId = database.Users.Where(u => u.Username == score.PlayerIds[0]).Select(u => u.UserId).First(),
},
HeartedLevelEntity heartedLevel => new LevelActivityEntity
{
@ -58,12 +59,42 @@ public class ActivityEntityEventHandler : IEntityEventHandler
TargetUserId = heartedProfile.HeartedUserId,
UserId = heartedProfile.UserId,
},
HeartedPlaylistEntity heartedPlaylist => new PlaylistActivityEntity
{
Type = EventType.HeartPlaylist,
PlaylistId = heartedPlaylist.PlaylistId,
UserId = heartedPlaylist.UserId,
},
VisitedLevelEntity visitedLevel => new LevelActivityEntity
{
Type = EventType.PlayLevel,
SlotId = visitedLevel.SlotId,
UserId = visitedLevel.UserId,
},
ReviewEntity review => new ReviewActivityEntity
{
Type = EventType.ReviewLevel,
ReviewId = review.ReviewId,
UserId = review.ReviewerId,
},
RatedLevelEntity ratedLevel => new LevelActivityEntity
{
Type = ratedLevel.Rating != 0 ? EventType.DpadRateLevel : EventType.RateLevel,
SlotId = ratedLevel.SlotId,
UserId = ratedLevel.UserId,
},
PlaylistEntity playlist => new PlaylistActivityEntity
{
Type = EventType.CreatePlaylist,
PlaylistId = playlist.PlaylistId,
UserId = playlist.CreatorId,
},
WebsiteAnnouncementEntity announcement => new NewsActivityEntity
{
Type = EventType.NewsPost,
UserId = announcement.PublisherId ?? 0,
NewsId = announcement.AnnouncementId,
},
_ => null,
};
InsertActivity(database, activity);
@ -82,6 +113,7 @@ public class ActivityEntityEventHandler : IEntityEventHandler
public void OnEntityChanged<T>(DatabaseContext database, T origEntity, T currentEntity) where T : class
{
#if DEBUG
foreach (PropertyInfo propInfo in currentEntity.GetType().GetProperties())
{
if (!propInfo.CanRead || !propInfo.CanWrite) continue;
@ -97,14 +129,19 @@ public class ActivityEntityEventHandler : IEntityEventHandler
Console.WriteLine($@"Orig val: {origVal?.ToString() ?? "null"}");
Console.WriteLine($@"New val: {newVal?.ToString() ?? "null"}");
}
Console.WriteLine($@"OnEntityChanged: {currentEntity.GetType().Name}");
#endif
ActivityEntity? activity = null;
switch (currentEntity)
{
case VisitedLevelEntity visitedLevel:
{
if (origEntity is not VisitedLevelEntity) break;
if (origEntity is not VisitedLevelEntity oldVisitedLevel) break;
int Plays(VisitedLevelEntity entity) => entity.PlaysLBP1 + entity.PlaysLBP2 + entity.PlaysLBP3;
if (Plays(oldVisitedLevel) >= Plays(visitedLevel)) break;
activity = new LevelActivityEntity
{
@ -118,25 +155,88 @@ public class ActivityEntityEventHandler : IEntityEventHandler
{
if (origEntity is not SlotEntity oldSlotEntity) break;
if (!oldSlotEntity.TeamPick && slotEntity.TeamPick)
switch (oldSlotEntity.TeamPick)
{
activity = new LevelActivityEntity
// When a level is team picked
case false when slotEntity.TeamPick:
activity = new LevelActivityEntity
{
Type = EventType.MMPickLevel,
SlotId = slotEntity.SlotId,
UserId = slotEntity.CreatorId,
};
break;
// When a level has its team pick removed then remove the corresponding activity
case true when !slotEntity.TeamPick:
database.Activities.OfType<LevelActivityEntity>()
.Where(a => a.Type == EventType.MMPickLevel)
.Where(a => a.SlotId == slotEntity.SlotId)
.ExecuteDelete();
break;
default:
{
Type = EventType.MMPickLevel,
SlotId = slotEntity.SlotId,
UserId = SlotHelper.GetPlaceholderUserId(database).Result,
};
}
else if (oldSlotEntity.SlotId == slotEntity.SlotId && slotEntity.Type == SlotType.User)
{
activity = new LevelActivityEntity
{
Type = EventType.PublishLevel,
SlotId = slotEntity.SlotId,
UserId = slotEntity.CreatorId,
};
}
if (oldSlotEntity.SlotId == slotEntity.SlotId &&
slotEntity.Type == SlotType.User &&
oldSlotEntity.LastUpdated != slotEntity.LastUpdated)
{
activity = new LevelActivityEntity
{
Type = EventType.PublishLevel,
SlotId = slotEntity.SlotId,
UserId = slotEntity.CreatorId,
};
}
break;
}
}
break;
}
case CommentEntity comment:
{
if (origEntity is not CommentEntity oldComment) break;
if (oldComment.Deleted || !comment.Deleted) break;
if (comment.Type != CommentType.Level) break;
activity = new CommentActivityEntity
{
Type = EventType.DeleteLevelComment,
CommentId = comment.CommentId,
UserId = comment.PosterUserId,
};
break;
}
case PlaylistEntity playlist:
{
if (origEntity is not PlaylistEntity oldPlaylist) break;
int[] newSlots = playlist.SlotIds;
int[] oldSlots = oldPlaylist.SlotIds;
Console.WriteLine($@"Old playlist slots: {string.Join(",", oldSlots)}");
Console.WriteLine($@"New playlist slots: {string.Join(",", newSlots)}");
int[] addedSlots = newSlots.Except(oldSlots).ToArray();
Console.WriteLine($@"Added playlist slots: {string.Join(",", addedSlots)}");
// If no new level have been added
if (addedSlots.Length == 0) break;
// Normally events only need 1 resulting ActivityEntity but here
// we need multiple, so we have to do the inserting ourselves.
foreach (int slotId in addedSlots)
{
ActivityEntity entity = new PlaylistWithSlotActivityEntity
{
Type = EventType.AddLevelToPlaylist,
PlaylistId = playlist.PlaylistId,
SlotId = slotId,
UserId = playlist.CreatorId,
};
InsertActivity(database, entity);
}
break;
}
}
@ -149,17 +249,6 @@ public class ActivityEntityEventHandler : IEntityEventHandler
Console.WriteLine($@"OnEntityDeleted: {entity.GetType().Name}");
ActivityEntity? activity = entity switch
{
//TODO move this to EntityModified and use CommentEntity.Deleted
CommentEntity comment => comment.Type switch
{
CommentType.Level => new CommentActivityEntity
{
Type = EventType.DeleteLevelComment,
CommentId = comment.CommentId,
UserId = comment.PosterUserId,
},
_ => null,
},
HeartedLevelEntity heartedLevel => new LevelActivityEntity
{
Type = EventType.UnheartLevel,

View file

@ -1,31 +1,59 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Xml.Serialization;
namespace LBPUnion.ProjectLighthouse.Types.Activity;
public class ActivityGroup
public struct ActivityGroup
{
public DateTime Timestamp { get; set; }
public int UserId { get; set; }
public int? TargetSlotId { get; set; }
public int? TargetUserId { get; set; }
public int? TargetPlaylistId { get; set; }
public int? TargetNewsId { get; set; }
public int? TargetTeamPickSlotId { get; set; }
public int TargetId =>
this.GroupType switch
{
ActivityGroupType.User => this.TargetUserId ?? 0,
ActivityGroupType.Level => this.TargetSlotId ?? 0,
ActivityGroupType.User => this.TargetUserId ?? this.UserId,
ActivityGroupType.Level => this.TargetSlotId?? 0,
ActivityGroupType.TeamPick => this.TargetTeamPickSlotId ?? 0,
ActivityGroupType.Playlist => this.TargetPlaylistId ?? 0,
ActivityGroupType.News => this.TargetNewsId ?? 0,
_ => this.UserId,
};
public ActivityGroupType GroupType =>
this.TargetSlotId != 0
(this.TargetSlotId ?? 0) != 0
? ActivityGroupType.Level
: this.TargetUserId != 0
: (this.TargetUserId ?? 0) != 0
? ActivityGroupType.User
: ActivityGroupType.Playlist;
: (this.TargetPlaylistId ?? 0) != 0
? ActivityGroupType.Playlist
: (this.TargetNewsId ?? 0) != 0
? ActivityGroupType.News
: (this.TargetTeamPickSlotId ?? 0) != 0
? ActivityGroupType.TeamPick
: ActivityGroupType.User;
public override string ToString() =>
$@"{this.GroupType} Group: Timestamp: {this.Timestamp}, UserId: {this.UserId}, TargetId: {this.TargetId}";
}
public struct OuterActivityGroup
{
public ActivityGroup Key { get; set; }
public List<IGrouping<InnerActivityGroup, ActivityDto>> Groups { get; set; }
}
public struct InnerActivityGroup
{
public ActivityGroupType Type { get; set; }
public int UserId { get; set; }
public int TargetId { get; set; }
}
public enum ActivityGroupType
@ -38,4 +66,10 @@ public enum ActivityGroupType
[XmlEnum("playlist")]
Playlist,
[XmlEnum("news")]
News,
[XmlEnum("slot")]
TeamPick,
}

View file

@ -2,68 +2,71 @@
namespace LBPUnion.ProjectLighthouse.Types.Activity;
/// <summary>
/// UnheartLevel, UnheartUser, DeleteLevelComment, and UnpublishLevel don't actually do anything
/// </summary>
public enum EventType
{
[XmlEnum("heart_level")]
HeartLevel,
HeartLevel = 0,
[XmlEnum("unheart_level")]
UnheartLevel,
UnheartLevel = 1,
[XmlEnum("heart_user")]
HeartUser,
HeartUser = 2,
[XmlEnum("unheart_user")]
UnheartUser,
UnheartUser = 3,
[XmlEnum("play_level")]
PlayLevel,
PlayLevel = 4,
[XmlEnum("rate_level")]
RateLevel,
RateLevel = 5,
[XmlEnum("tag_level")]
TagLevel,
TagLevel = 6,
[XmlEnum("comment_on_level")]
CommentOnLevel,
CommentOnLevel = 7,
[XmlEnum("delete_level_comment")]
DeleteLevelComment,
DeleteLevelComment = 8,
[XmlEnum("upload_photo")]
UploadPhoto,
UploadPhoto = 9,
[XmlEnum("publish_level")]
PublishLevel,
PublishLevel = 10,
[XmlEnum("unpublish_level")]
UnpublishLevel,
UnpublishLevel = 11,
[XmlEnum("score")]
Score,
Score = 12,
[XmlEnum("news_post")]
NewsPost,
NewsPost = 13,
[XmlEnum("mm_pick_level")]
MMPickLevel,
MMPickLevel = 14,
[XmlEnum("dpad_rate_level")]
DpadRateLevel,
DpadRateLevel = 15,
[XmlEnum("review_level")]
ReviewLevel,
ReviewLevel = 16,
[XmlEnum("comment_on_user")]
CommentOnUser,
CommentOnUser = 17,
[XmlEnum("create_playlist")]
CreatePlaylist,
CreatePlaylist = 18,
[XmlEnum("heart_playlist")]
HeartPlaylist,
HeartPlaylist = 19,
[XmlEnum("add_level_to_playlist")]
AddLevelToPlaylist,
AddLevelToPlaylist = 20,
}

View file

@ -4,14 +4,13 @@ using LBPUnion.ProjectLighthouse.Types.Entities.Level;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
/// <summary>
/// Supported event types: play_level, heart_level, publish_level,
/// Supported event types: play_level, heart_level, publish_level, unheart_level, dpad_rate_level, rate_level, tag_level, mm_pick_level
/// </summary>
public class LevelActivityEntity : ActivityEntity
{
[Column("SlotId")]
public int SlotId { get; set; }
[ForeignKey(nameof(SlotId))]
public SlotEntity Slot { get; set; }
}

View file

@ -1,11 +1,15 @@
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
using System.ComponentModel.DataAnnotations.Schema;
using LBPUnion.ProjectLighthouse.Types.Entities.Website;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
/// <summary>
/// Supported event types: NewsPost
/// </summary>
public class NewsActivityEntity : ActivityEntity
{
public string Title { get; set; } = "";
public int NewsId { get; set; }
public string Body { get; set; } = "";
[ForeignKey(nameof(NewsId))]
public WebsiteAnnouncementEntity News { get; set; }
}

View file

@ -4,12 +4,37 @@ using LBPUnion.ProjectLighthouse.Types.Entities.Level;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
/// <summary>
/// Supported event types: CreatePlaylist, HeartPlaylist, AddLevelToPlaylist
/// Supported event types: CreatePlaylist, HeartPlaylist
/// </summary>
public class PlaylistActivityEntity : ActivityEntity
{
[Column("PlaylistId")]
public int PlaylistId { get; set; }
[ForeignKey(nameof(PlaylistId))]
public PlaylistEntity Playlist { get; set; }
}
/// <summary>
/// Supported event types: AddLevelToPlaylist
/// <para>
/// The relationship between <see cref="PlaylistActivityEntity"/> and <see cref="PlaylistWithSlotActivityEntity"/>
/// is slightly hacky but it allows conditional reuse of columns from other ActivityEntity's
///
/// </para>
/// </summary>
public class PlaylistWithSlotActivityEntity : ActivityEntity
{
[Column("PlaylistId")]
public int PlaylistId { get; set; }
[ForeignKey(nameof(PlaylistId))]
public PlaylistEntity Playlist { get; set; }
/// <summary>
/// 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>
/// </summary>
[Column("SlotId")]
public int SlotId { get; set; }
}

View file

@ -3,12 +3,13 @@ using LBPUnion.ProjectLighthouse.Types.Entities.Level;
namespace LBPUnion.ProjectLighthouse.Types.Entities.Activity;
/// <summary>
/// Supported event types: DpadRateLevel, ReviewLevel, RateLevel, TagLevel
/// </summary>
public class ReviewActivityEntity : ActivityEntity
{
public int ReviewId { get; set; }
[ForeignKey(nameof(ReviewId))]
public ReviewEntity Review { get; set; }
// TODO review_modified?
}

View file

@ -0,0 +1,6 @@
using LBPUnion.ProjectLighthouse.Types.Activity;
namespace LBPUnion.ProjectLighthouse.Types.Filter;
public interface IActivityFilter : IFilter<ActivityDto>
{ }

View file

@ -0,0 +1,26 @@
using System.Threading.Tasks;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Serialization.Review;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity.Events;
public class GameAddLevelToPlaylistEvent : GameEvent
{
[XmlElement("object_playlist_id")]
public int TargetPlaylistId { get; set; }
[XmlElement("object_slot_id")]
public ReviewSlot Slot { get; set; }
public new async Task PrepareSerialization(DatabaseContext database)
{
await base.PrepareSerialization(database);
SlotEntity slot = await database.Slots.FindAsync(this.Slot.SlotId);
if (slot == null) return;
this.Slot = ReviewSlot.CreateFromEntity(slot);
}
}

View file

@ -0,0 +1,16 @@
using System.Threading.Tasks;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Database;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity.Events;
public class GameCreatePlaylistEvent : GameEvent
{
[XmlElement("object_playlist_id")]
public int TargetPlaylistId { get; set; }
public new async Task PrepareSerialization(DatabaseContext database)
{
await base.PrepareSerialization(database);
}
}

View file

@ -0,0 +1,32 @@
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Serialization.Review;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity.Events;
public class GameDpadRateLevelEvent : GameEvent
{
[XmlElement("object_slot_id")]
public ReviewSlot Slot { get; set; }
[XmlElement("dpad_rating")]
public int Rating { get; set; }
public new async Task PrepareSerialization(DatabaseContext database)
{
await base.PrepareSerialization(database);
SlotEntity slot = await database.Slots.FindAsync(this.Slot.SlotId);
if (slot == null) return;
this.Slot = ReviewSlot.CreateFromEntity(slot);
this.Rating = await database.RatedLevels.Where(r => r.SlotId == slot.SlotId && r.UserId == this.UserId)
.Select(r => r.Rating)
.FirstOrDefaultAsync();
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Serialization;
@ -19,10 +20,19 @@ namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity.Events;
[XmlInclude(typeof(GameScoreEvent))]
[XmlInclude(typeof(GameHeartLevelEvent))]
[XmlInclude(typeof(GameHeartUserEvent))]
[XmlInclude(typeof(GameHeartPlaylistEvent))]
[XmlInclude(typeof(GameReviewEvent))]
[XmlInclude(typeof(GamePublishLevelEvent))]
[XmlInclude(typeof(GameRateLevelEvent))]
[XmlInclude(typeof(GameDpadRateLevelEvent))]
[XmlInclude(typeof(GameTeamPickLevelEvent))]
[XmlInclude(typeof(GameNewsEvent))]
[XmlInclude(typeof(GameCreatePlaylistEvent))]
[XmlInclude(typeof(GameAddLevelToPlaylistEvent))]
public class GameEvent : ILbpSerializable, INeedsPreparationForSerialization
{
[XmlIgnore]
private int UserId { get; set; }
protected int UserId { get; set; }
[XmlAttribute("type")]
public EventType Type { get; set; }
@ -31,100 +41,190 @@ public class GameEvent : ILbpSerializable, INeedsPreparationForSerialization
public long Timestamp { get; set; }
[XmlElement("actor")]
[DefaultValue(null)]
public string Username { get; set; }
protected async Task PrepareSerialization(DatabaseContext database)
{
Console.WriteLine($@"SERIALIZATION!! {this.UserId} - {this.GetHashCode()}");
Console.WriteLine($@"EVENT SERIALIZATION!! {this.UserId} - {this.GetHashCode()}");
UserEntity user = await database.Users.FindAsync(this.UserId);
if (user == null) return;
this.Username = user.Username;
}
public static IEnumerable<GameEvent> CreateFromActivityGroups(IGrouping<EventType, ActivityEntity> group)
public static IEnumerable<GameEvent> CreateFromActivities(IEnumerable<ActivityDto> activities)
{
List<GameEvent> events = new();
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
// Events with Count need special treatment
switch (group.Key)
List<IGrouping<EventType, ActivityDto>> typeGroups = activities.GroupBy(g => g.Activity.Type).ToList();
foreach (IGrouping<EventType, ActivityDto> typeGroup in typeGroups)
{
case EventType.PlayLevel:
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
// Events with Count need special treatment
switch (typeGroup.Key)
{
if (group.First() is not LevelActivityEntity levelActivity) break;
events.Add(new GamePlayLevelEvent
case EventType.PlayLevel:
{
Slot = new ReviewSlot
{
SlotId = levelActivity.SlotId,
},
Count = group.Count(),
UserId = levelActivity.UserId,
Timestamp = levelActivity.Timestamp.ToUnixTimeMilliseconds(),
Type = levelActivity.Type,
});
break;
}
case EventType.PublishLevel:
{
if (group.First() is not LevelActivityEntity levelActivity) break;
if (typeGroup.First().Activity is not LevelActivityEntity levelActivity) break;
events.Add(new GamePublishLevelEvent
{
Slot = new ReviewSlot
events.Add(new GamePlayLevelEvent
{
SlotId = levelActivity.SlotId,
},
Count = group.Count(),
UserId = levelActivity.UserId,
Timestamp = levelActivity.Timestamp.ToUnixTimeMilliseconds(),
Type = levelActivity.Type,
});
break;
Slot = new ReviewSlot
{
SlotId = levelActivity.SlotId,
},
Count = typeGroup.Count(),
UserId = levelActivity.UserId,
Timestamp = levelActivity.Timestamp.ToUnixTimeMilliseconds(),
Type = levelActivity.Type,
});
break;
}
// Everything else can be handled as normal
default:
events.AddRange(typeGroup.Select(CreateFromActivity).Where(a => a != null));
break;
}
// Everything else can be handled as normal
default: events.AddRange(group.Select(CreateFromActivity));
break;
}
return events.AsEnumerable();
}
private static GameEvent CreateFromActivity(ActivityEntity activity)
private static bool IsValidActivity(ActivityEntity activity)
{
GameEvent gameEvent = activity.Type switch
return activity switch
{
CommentActivityEntity => activity.Type is EventType.CommentOnLevel or EventType.CommentOnUser
or EventType.DeleteLevelComment,
LevelActivityEntity => activity.Type is EventType.PlayLevel or EventType.HeartLevel
or EventType.UnheartLevel or EventType.DpadRateLevel or EventType.RateLevel or EventType.MMPickLevel
or EventType.PublishLevel or EventType.TagLevel,
NewsActivityEntity => activity.Type is EventType.NewsPost,
PhotoActivityEntity => activity.Type is EventType.UploadPhoto,
PlaylistActivityEntity => activity.Type is EventType.CreatePlaylist or EventType.HeartPlaylist,
PlaylistWithSlotActivityEntity => activity.Type is EventType.AddLevelToPlaylist,
ReviewActivityEntity => activity.Type is EventType.ReviewLevel,
ScoreActivityEntity => activity.Type is EventType.Score,
UserActivityEntity => activity.Type is EventType.HeartUser or EventType.UnheartUser
or EventType.CommentOnUser,
_ => false,
};
}
private static GameEvent CreateFromActivity(ActivityDto activity)
{
if (!IsValidActivity(activity.Activity))
{
Console.WriteLine(@"Invalid Activity: " + activity.Activity.ActivityId);
return null;
}
int targetId = activity.TargetId;
GameEvent gameEvent = activity.Activity.Type switch
{
EventType.PlayLevel => new GamePlayLevelEvent
{
Slot = new ReviewSlot
{
SlotId = ((LevelActivityEntity)activity).SlotId,
SlotId = targetId,
},
},
EventType.CommentOnLevel => new GameSlotCommentEvent
{
CommentId = ((CommentActivityEntity)activity).CommentId,
},
EventType.CommentOnUser => new GameUserCommentEvent
{
CommentId = ((CommentActivityEntity)activity).CommentId,
},
EventType.HeartUser or EventType.UnheartUser => new GameHeartUserEvent
{
TargetUserId = ((UserActivityEntity)activity).TargetUserId,
},
EventType.HeartLevel or EventType.UnheartLevel => new GameHeartLevelEvent
{
TargetSlot = new ReviewSlot
{
SlotId = ((LevelActivityEntity)activity).SlotId,
SlotId = targetId,
},
},
EventType.DpadRateLevel => new GameDpadRateLevelEvent
{
Slot = new ReviewSlot
{
SlotId = targetId,
},
},
EventType.Score => new GameScoreEvent
{
ScoreId = ((ScoreActivityEntity)activity.Activity).ScoreId,
Slot = new ReviewSlot
{
SlotId = targetId,
},
},
EventType.RateLevel => new GameRateLevelEvent
{
Slot = new ReviewSlot
{
SlotId = targetId
},
},
EventType.CommentOnLevel => new GameSlotCommentEvent
{
CommentId = ((CommentActivityEntity)activity.Activity).CommentId,
},
EventType.CommentOnUser => new GameUserCommentEvent
{
CommentId = ((CommentActivityEntity)activity.Activity).CommentId,
},
EventType.HeartUser or EventType.UnheartUser => new GameHeartUserEvent
{
TargetUserId = targetId,
},
EventType.ReviewLevel => new GameReviewEvent
{
ReviewId = ((ReviewActivityEntity)activity.Activity).ReviewId,
Slot = new ReviewSlot
{
SlotId = targetId,
},
},
EventType.UploadPhoto => new GamePhotoUploadEvent
{
Slot = new ReviewSlot
{
SlotId = targetId,
},
},
EventType.MMPickLevel => new GameTeamPickLevelEvent
{
Slot = new ReviewSlot
{
SlotId = targetId,
},
},
EventType.PublishLevel => new GamePublishLevelEvent
{
Slot = new ReviewSlot
{
SlotId = targetId,
},
Count = 1,
},
EventType.NewsPost => new GameNewsEvent
{
NewsId = targetId,
},
EventType.CreatePlaylist => new GameCreatePlaylistEvent
{
TargetPlaylistId = targetId,
},
EventType.HeartPlaylist => new GameHeartPlaylistEvent
{
TargetPlaylistId = targetId,
},
EventType.AddLevelToPlaylist => new GameAddLevelToPlaylistEvent
{
TargetPlaylistId = targetId,
Slot = new ReviewSlot
{
SlotId = ((PlaylistWithSlotActivityEntity)activity.Activity).SlotId,
},
},
_ => new GameEvent(),
};
gameEvent.UserId = activity.UserId;
gameEvent.Type = activity.Type;
gameEvent.Timestamp = activity.Timestamp.ToUnixTimeMilliseconds();
gameEvent.UserId = activity.Activity.UserId;
gameEvent.Type = activity.Activity.Type;
gameEvent.Timestamp = activity.Activity.Timestamp.ToUnixTimeMilliseconds();
return gameEvent;
}
}

View file

@ -40,4 +40,15 @@ public class GameHeartLevelEvent : GameEvent
this.TargetSlot = ReviewSlot.CreateFromEntity(slot);
}
}
public class GameHeartPlaylistEvent : GameEvent
{
[XmlElement("object_playlist_id")]
public int TargetPlaylistId { get; set; }
public new async Task PrepareSerialization(DatabaseContext database)
{
await base.PrepareSerialization(database);
}
}

View file

@ -0,0 +1,9 @@
using System.Xml.Serialization;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity.Events;
public class GameNewsEvent : GameEvent
{
[XmlElement("news_id")]
public int NewsId { get; set; }
}

View file

@ -18,7 +18,7 @@ public class GamePhotoUploadEvent : GameEvent
[XmlElement("object_slot_id")]
[DefaultValue(null)]
public ReviewSlot SlotId { get; set; }
public ReviewSlot Slot { get; set; }
[XmlElement("user_in_photo")]
public List<string> PhotoParticipants { get; set; }
@ -40,6 +40,6 @@ public class GamePhotoUploadEvent : GameEvent
SlotEntity slot = await database.Slots.FindAsync(photo.SlotId);
if (slot == null) return;
this.SlotId = ReviewSlot.CreateFromEntity(slot);
this.Slot = ReviewSlot.CreateFromEntity(slot);
}
}

View file

@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System;
using System.Threading.Tasks;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
@ -12,7 +13,7 @@ public class GamePublishLevelEvent : GameEvent
public ReviewSlot Slot { get; set; }
[XmlElement("republish")]
public bool IsRepublish { get; set; }
public int IsRepublish { get; set; }
[XmlElement("count")]
public int Count { get; set; }
@ -26,6 +27,7 @@ public class GamePublishLevelEvent : GameEvent
this.Slot = ReviewSlot.CreateFromEntity(slot);
// TODO does this work?
this.IsRepublish = slot.LastUpdated == slot.FirstUploaded;
bool republish = Math.Abs(this.Timestamp - slot.FirstUploaded) > 5000;
this.IsRepublish = Convert.ToInt32(republish);
}
}

View file

@ -0,0 +1,32 @@
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Serialization.Review;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity.Events;
public class GameRateLevelEvent : GameEvent
{
[XmlElement("object_slot_id")]
public ReviewSlot Slot { get; set; }
[XmlElement("rating")]
public double Rating { get; set; }
public new async Task PrepareSerialization(DatabaseContext database)
{
await base.PrepareSerialization(database);
SlotEntity slot = await database.Slots.FindAsync(this.Slot.SlotId);
if (slot == null) return;
this.Slot = ReviewSlot.CreateFromEntity(slot);
this.Rating = await database.RatedLevels.Where(r => r.SlotId == slot.SlotId && r.UserId == this.UserId)
.Select(r => r.RatingLBP1)
.FirstOrDefaultAsync();
}
}

View file

@ -1,4 +1,5 @@
using System.ComponentModel;
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Database;
@ -9,7 +10,7 @@ namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity.Events;
public class GameReviewEvent : GameEvent
{
[XmlElement("slot_id")]
[XmlElement("object_slot_id")]
public ReviewSlot Slot { get; set; }
[XmlElement("review_id")]
@ -21,9 +22,13 @@ public class GameReviewEvent : GameEvent
public new async Task PrepareSerialization(DatabaseContext database)
{
await base.PrepareSerialization(database);
ReviewEntity review = await database.Reviews.FindAsync(this.ReviewId);
if (review == null) return;
this.ReviewTimestamp = this.Timestamp;
SlotEntity slot = await database.Slots.FindAsync(review.SlotId);
if (slot == null) return;

View file

@ -0,0 +1,24 @@
using System.Threading.Tasks;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Serialization.Review;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity.Events;
public class GameTeamPickLevelEvent : GameEvent
{
[XmlElement("object_slot_id")]
public ReviewSlot Slot { get; set; }
public new async Task PrepareSerialization(DatabaseContext database)
{
SlotEntity slot = await database.Slots.FindAsync(this.Slot.SlotId);
if (slot == null) return;
this.Slot = ReviewSlot.CreateFromEntity(slot);
// Don't serialize usernames for team picks
this.Username = null;
}
}

View file

@ -0,0 +1,9 @@
using System.Xml.Serialization;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity;
public class GameNewsStreamGroup : GameStreamGroup
{
[XmlElement("news_id")]
public int NewsId { get; set; }
}

View file

@ -0,0 +1,9 @@
using System.Xml.Serialization;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity;
public class GamePlaylistStreamGroup : GameStreamGroup
{
[XmlElement("playlist_id")]
public int PlaylistId { get; set; }
}

View file

@ -1,18 +1,21 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
using LBPUnion.ProjectLighthouse.Types.Entities.Token;
using LBPUnion.ProjectLighthouse.Types.Entities.Website;
using LBPUnion.ProjectLighthouse.Types.Serialization.News;
using LBPUnion.ProjectLighthouse.Types.Serialization.Playlist;
using LBPUnion.ProjectLighthouse.Types.Serialization.Slot;
using LBPUnion.ProjectLighthouse.Types.Serialization.User;
using LBPUnion.ProjectLighthouse.Types.Users;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity;
@ -23,10 +26,16 @@ namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity;
public class GameStream : ILbpSerializable, INeedsPreparationForSerialization
{
[XmlIgnore]
private List<int> SlotIds { get; set; }
public List<int> SlotIds { get; set; }
[XmlIgnore]
private List<int> UserIds { get; set; }
public List<int> UserIds { get; set; }
[XmlIgnore]
public List<int> PlaylistIds { get; set; }
[XmlIgnore]
public List<int> NewsIds { get; set; }
[XmlIgnore]
private int TargetUserId { get; set; }
@ -42,84 +51,85 @@ public class GameStream : ILbpSerializable, INeedsPreparationForSerialization
[XmlArray("groups")]
[XmlArrayItem("group")]
[DefaultValue(null)]
public List<GameStreamGroup> Groups { get; set; }
[XmlArray("slots")]
[XmlArrayItem("slot")]
[DefaultValue(null)]
public List<SlotBase> Slots { get; set; }
[XmlArray("users")]
[XmlArrayItem("user")]
[DefaultValue(null)]
public List<GameUser> Users { get; set; }
[XmlArray("playlists")]
[XmlArrayItem("playlist")]
[DefaultValue(null)]
public List<GamePlaylist> Playlists { get; set; }
[XmlArray("news")]
[XmlArrayItem("item")]
public List<object> News { get; set; }
//TODO implement lbp1 and lbp2 news objects
[DefaultValue(null)]
public List<GameNewsObject> News { get; set; }
public async Task PrepareSerialization(DatabaseContext database)
{
if (this.SlotIds.Count > 0)
async Task<List<TResult>> LoadEntities<TFrom, TResult>(List<int> ids, Func<TFrom, TResult> transformation)
where TFrom : class
{
this.Slots = new List<SlotBase>();
foreach (int slotId in this.SlotIds)
List<TResult> results = new();
if (ids.Count <= 0) return null;
foreach (int id in ids)
{
SlotEntity slot = await database.Slots.FindAsync(slotId);
if (slot == null) continue;
TFrom entity = await database.Set<TFrom>().FindAsync(id);
if (entity == null) continue;
this.Slots.Add(SlotBase.CreateFromEntity(slot, this.TargetGame, this.TargetUserId));
results.Add(transformation(entity));
}
return results;
}
if (this.UserIds.Count > 0)
{
this.Users = new List<GameUser>();
foreach (int userId in this.UserIds)
{
UserEntity user = await database.Users.FindAsync(userId);
if (user == null) continue;
this.Users.Add(GameUser.CreateFromEntity(user, this.TargetGame));
}
}
this.Slots = await LoadEntities<SlotEntity, SlotBase>(this.SlotIds, slot => SlotBase.CreateFromEntity(slot, this.TargetGame, this.TargetUserId));
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.News = await LoadEntities<WebsiteAnnouncementEntity, GameNewsObject>(this.NewsIds, GameNewsObject.CreateFromEntity);
}
public static async Task<GameStream> CreateFromEntityResult
(
DatabaseContext database,
GameTokenEntity token,
List<IGrouping<ActivityGroup, ActivityEntity>> results,
long startTimestamp,
long endTimestamp
)
public static GameStream CreateFromGroups
(GameTokenEntity token, List<OuterActivityGroup> groups, long startTimestamp, long endTimestamp)
{
List<int> slotIds = results.Where(g => g.Key.TargetSlotId != null && g.Key.TargetSlotId.Value != 0)
.Select(g => g.Key.TargetSlotId.Value)
.ToList();
Console.WriteLine($@"slotIds: {string.Join(",", slotIds)}");
List<int> userIds = results.Where(g => g.Key.TargetUserId != null && g.Key.TargetUserId.Value != 0)
.Select(g => g.Key.TargetUserId.Value)
.Distinct()
.Union(results.Select(g => g.Key.UserId))
.ToList();
// Cache target levels and users within DbContext
await database.Slots.Where(s => slotIds.Contains(s.SlotId)).LoadAsync();
await database.Users.Where(u => userIds.Contains(u.UserId)).LoadAsync();
Console.WriteLine($@"userIds: {string.Join(",", userIds)}");
Console.WriteLine($@"Stream contains {slotIds.Count} slots and {userIds.Count} users");
GameStream gameStream = new()
{
TargetUserId = token.UserId,
TargetGame = token.GameVersion,
StartTimestamp = startTimestamp,
EndTimestamp = endTimestamp,
SlotIds = slotIds,
UserIds = userIds,
Groups = new List<GameStreamGroup>(),
SlotIds = groups.GetIds(ActivityGroupType.Level),
UserIds = groups.GetIds(ActivityGroupType.User),
PlaylistIds = groups.GetIds(ActivityGroupType.Playlist),
NewsIds = groups.GetIds(ActivityGroupType.News),
};
foreach (IGrouping<ActivityGroup, ActivityEntity> group in results)
if (groups.Count == 0) return gameStream;
gameStream.Groups = groups.Select(GameStreamGroup.CreateFromGroup).ToList();
// Workaround for level activity because it shouldn't contain nested activity groups
if (gameStream.Groups.Count == 1 && groups.First().Key.GroupType == ActivityGroupType.Level)
{
gameStream.Groups.Add(GameStreamGroup.CreateFromGrouping(group));
gameStream.Groups = gameStream.Groups.First().Groups;
}
// Workaround to turn a single subgroup into the primary group for news and team picks
for (int i = 0; i < gameStream.Groups.Count; i++)
{
GameStreamGroup group = gameStream.Groups[i];
if (group.Type is not (ActivityGroupType.TeamPick or ActivityGroupType.News)) continue;
if (group.Groups.Count > 1) continue;
gameStream.Groups[i] = group.Groups.First();
}
return gameStream;

View file

@ -0,0 +1,26 @@
#nullable enable
using System.Collections.Generic;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Types.Activity;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity;
[XmlRoot("stream")]
// This class should only be deserialized
public class GameStreamFilter
{
[XmlArray("sources")]
[XmlArrayItem("source")]
public List<GameStreamFilterEventSource>? Sources { get; set; }
}
[XmlRoot("source")]
public class GameStreamFilterEventSource
{
[XmlAttribute("type")]
public string? SourceType { get; set; }
[XmlArray("event_filters")]
[XmlArrayItem("event_filter")]
public List<EventType>? Types { get; set; }
}

View file

@ -3,8 +3,8 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Extensions;
using LBPUnion.ProjectLighthouse.Types.Activity;
using LBPUnion.ProjectLighthouse.Types.Entities.Activity;
using LBPUnion.ProjectLighthouse.Types.Serialization.Activity.Events;
using LBPUnion.ProjectLighthouse.Types.Serialization.Review;
@ -19,6 +19,8 @@ namespace LBPUnion.ProjectLighthouse.Types.Serialization.Activity;
/// </summary>
[XmlInclude(typeof(GameUserStreamGroup))]
[XmlInclude(typeof(GameSlotStreamGroup))]
[XmlInclude(typeof(GamePlaylistStreamGroup))]
[XmlInclude(typeof(GameNewsStreamGroup))]
public class GameStreamGroup : ILbpSerializable
{
[XmlAttribute("type")]
@ -35,46 +37,62 @@ public class GameStreamGroup : ILbpSerializable
[XmlArray("events")]
[XmlArrayItem("event")]
[DefaultValue(null)]
// ReSharper disable once MemberCanBePrivate.Global
// (the serializer can't see this if it's private)
public List<GameEvent> Events { get; set; }
public static GameStreamGroup CreateFromGrouping(IGrouping<ActivityGroup, ActivityEntity> group)
public static GameStreamGroup CreateFromGroup(OuterActivityGroup group)
{
GameStreamGroup gameGroup = CreateGroup(group.Key.GroupType,
group.Key.TargetId,
streamGroup =>
{
streamGroup.Timestamp = group.Groups
.Max(g => g.MaxBy(a => a.Activity.Timestamp)?.Activity.Timestamp ?? group.Key.Timestamp)
.ToUnixTimeMilliseconds();
});
gameGroup.Groups = new List<GameStreamGroup>(group.Groups.Select(g => CreateGroup(g.Key.Type,
g.Key.TargetId,
streamGroup =>
{
streamGroup.Timestamp =
g.MaxBy(a => a.Activity.Timestamp).Activity.Timestamp.ToUnixTimeMilliseconds();
streamGroup.Events = GameEvent.CreateFromActivities(g).ToList();
}))
.ToList());
return gameGroup;
}
private static GameStreamGroup CreateGroup
(ActivityGroupType type, int targetId, Action<GameStreamGroup> groupAction)
{
ActivityGroupType type = group.Key.GroupType;
GameStreamGroup gameGroup = type switch
{
ActivityGroupType.Level => new GameSlotStreamGroup
ActivityGroupType.Level or ActivityGroupType.TeamPick => new GameSlotStreamGroup
{
Slot = new ReviewSlot
{
SlotId = group.Key.TargetId,
SlotId = targetId,
},
},
ActivityGroupType.User => new GameUserStreamGroup
{
UserId = group.Key.TargetId,
UserId = targetId,
},
ActivityGroupType.Playlist => new GamePlaylistStreamGroup
{
PlaylistId = targetId,
},
ActivityGroupType.News => new GameNewsStreamGroup
{
NewsId = targetId,
},
_ => new GameStreamGroup(),
};
gameGroup.Timestamp = new DateTimeOffset(group.Select(a => a.Timestamp).MaxBy(a => a)).ToUnixTimeMilliseconds();
gameGroup.Type = type;
List<IGrouping<EventType, ActivityEntity>> eventGroups = group.OrderByDescending(a => a.Timestamp).GroupBy(g => g.Type).ToList();
//TODO removeme debug
foreach (IGrouping<EventType, ActivityEntity> bruh in eventGroups)
{
Console.WriteLine($@"group key: {bruh.Key}, count={bruh.Count()}");
}
gameGroup.Groups = new List<GameStreamGroup>
{
new GameUserStreamGroup
{
UserId = group.Key.UserId,
Type = ActivityGroupType.User,
Timestamp = gameGroup.Timestamp,
Events = eventGroups.SelectMany(GameEvent.CreateFromActivityGroups).ToList(),
},
};
groupAction(gameGroup);
return gameGroup;
}
}

View file

@ -0,0 +1,63 @@
using System.Collections.Generic;
using System.Linq;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Types.Entities.Website;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.News;
/// <summary>
/// Used in LBP1 only
/// </summary>
[XmlRoot("news")]
public class GameNews : ILbpSerializable
{
[XmlElement("subcategory")]
public List<GameNewsSubcategory> Entries { get; set; }
public static GameNews CreateFromEntity(List<WebsiteAnnouncementEntity> entities) =>
new()
{
Entries = entities.Select(entity => new GameNewsSubcategory
{
Item = new GameNewsItem
{
Content = new GameNewsContent
{
Frame = new GameNewsFrame
{
Title = entity.Title,
Width = 512,
Container = new List<GameNewsFrameContainer>
{
new()
{
Content = entity.Content,
Width = 512,
},
},
},
},
},
})
.ToList(),
};
}
[XmlRoot("subcategory")]
public class GameNewsSubcategory : ILbpSerializable
{
[XmlElement("item")]
public GameNewsItem Item { get; set; }
}
public class GameNewsItem : ILbpSerializable
{
[XmlElement("content")]
public GameNewsContent Content { get; set; }
}
public class GameNewsContent : ILbpSerializable
{
[XmlElement("frame")]
public GameNewsFrame Frame { get; set; }
}

View file

@ -0,0 +1,40 @@
#nullable enable
using System.Collections.Generic;
using System.ComponentModel;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Types.Serialization.Slot;
using LBPUnion.ProjectLighthouse.Types.Serialization.User;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.News;
[XmlRoot("frame")]
public class GameNewsFrame : ILbpSerializable
{
[XmlAttribute("width")]
public int Width { get; set; }
[XmlElement("title")]
public string Title { get; set; } = "";
[XmlElement("item")]
[DefaultValue(null)]
public List<GameNewsFrameContainer>? Container { get; set; }
}
public class GameNewsFrameContainer : ILbpSerializable
{
[XmlAttribute("width")]
public int Width { get; set; }
[XmlElement("content")]
[DefaultValue(null)]
public string Content { get; set; } = "";
[XmlElement("npHandle")]
[DefaultValue(null)]
public MinimalUserProfile? User { get; set; }
[XmlElement("slot")]
[DefaultValue(null)]
public MinimalSlot? Slot { get; set; }
}

View file

@ -0,0 +1,54 @@
using System.ComponentModel;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Types.Entities.Website;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.News;
/// <summary>
/// Used in LBP2 and beyond
/// </summary>
[XmlRoot("item")]
public class GameNewsObject : ILbpSerializable
{
[XmlElement("id")]
public int Id { get; set; }
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("summary")]
public string Summary { get; set; }
[XmlElement("text")]
public string Text { get; set; }
[XmlElement("date")]
public long Timestamp { get; set; }
[XmlElement("image")]
[DefaultValue(null)]
public GameNewsImage Image { get; set; }
[XmlElement("category")]
public string Category { get; set; }
public static GameNewsObject CreateFromEntity(WebsiteAnnouncementEntity entity) =>
new()
{
Id = entity.AnnouncementId,
Title = entity.Title,
Summary = "there's an extra spot for summary here",
Text = entity.Content,
Category = "no_category",
};
}
[XmlRoot("image")]
public class GameNewsImage : ILbpSerializable
{
[XmlElement("hash")]
public string Hash { get; set; }
[XmlElement("alignment")]
public string Alignment { get; set; }
}

View file

@ -30,7 +30,6 @@ public class GamePlaylist : ILbpSerializable, INeedsPreparationForSerialization
[XmlElement("name")]
public string Name { get; set; } = "";
[DefaultValue("")]
[XmlElement("description")]
public string Description { get; set; } = "";
@ -62,6 +61,7 @@ public class GamePlaylist : ILbpSerializable, INeedsPreparationForSerialization
Username = authorUsername,
};
this.LevelCount = this.SlotIds.Length;
this.Hearts = await database.HeartedPlaylists.CountAsync(h => h.HeartedPlaylistId == this.PlaylistId);
this.PlaylistQuota = ServerConfiguration.Instance.UserGeneratedContentLimits.ListsQuota;
List<string> iconList = this.SlotIds.Select(id => database.Slots.FirstOrDefault(s => s.SlotId == id))

View file

@ -0,0 +1,22 @@
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Types.Entities.Level;
using LBPUnion.ProjectLighthouse.Types.Levels;
namespace LBPUnion.ProjectLighthouse.Types.Serialization.Slot;
[XmlRoot("slot")]
public class MinimalSlot : ILbpSerializable
{
[XmlElement("type")]
public SlotType Type { get; set; }
[XmlElement("id")]
public int SlotId { get; set; }
public MinimalSlot CreateFromEntity(SlotEntity slot) =>
new()
{
Type = slot.Type,
SlotId = slot.SlotId,
};
}

View file

@ -1,4 +1,6 @@
using System.ComponentModel;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Serialization;
@ -185,15 +187,21 @@ public class GameUser : ILbpSerializable, INeedsPreparationForSerialization
int entitledSlots = ServerConfiguration.Instance.UserGeneratedContentLimits.EntitledSlots + stats.BonusSlots;
IQueryable<SlotEntity> SlotCount(GameVersion version)
{
return database.Slots.Where(s => s.CreatorId == this.UserId && s.GameVersion == version);
}
Dictionary<GameVersion, int> slotsByGame = await database.Slots.Where(s => s.CreatorId == this.UserId && !s.CrossControllerRequired)
.GroupBy(s => s.GameVersion)
.Select(g => new
{
Game = g.Key,
Count = g.Count(),
})
.ToDictionaryAsync(k => k.Game, k => k.Count);
int GetSlotCount(GameVersion version) => slotsByGame.TryGetValue(version, out int count) ? count : 0;
if (this.TargetGame == GameVersion.LittleBigPlanetVita)
{
this.Lbp2EntitledSlots = entitledSlots;
this.Lbp2UsedSlots = await SlotCount(GameVersion.LittleBigPlanetVita).CountAsync();
this.Lbp2UsedSlots = GetSlotCount(GameVersion.LittleBigPlanetVita);
}
else
{
@ -201,9 +209,9 @@ public class GameUser : ILbpSerializable, INeedsPreparationForSerialization
this.Lbp2EntitledSlots = entitledSlots;
this.CrossControlEntitledSlots = entitledSlots;
this.Lbp3EntitledSlots = entitledSlots;
this.Lbp1UsedSlots = await SlotCount(GameVersion.LittleBigPlanet1).CountAsync();
this.Lbp2UsedSlots = await SlotCount(GameVersion.LittleBigPlanet2).CountAsync(s => !s.CrossControllerRequired);
this.Lbp3UsedSlots = await SlotCount(GameVersion.LittleBigPlanet3).CountAsync();
this.Lbp1UsedSlots = GetSlotCount(GameVersion.LittleBigPlanet1);
this.Lbp2UsedSlots = GetSlotCount(GameVersion.LittleBigPlanet2);
this.Lbp3UsedSlots = GetSlotCount(GameVersion.LittleBigPlanet3);
this.Lbp1FreeSlots = this.Lbp1EntitledSlots - this.Lbp1UsedSlots;