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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 6 additions & 12 deletions Refresh.Database/GameDatabaseContext.Comments.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,10 @@ public GameProfileComment PostCommentToProfile(GameUser profile, GameUser author
return comment;
}

public IEnumerable<GameProfileComment> GetProfileComments(GameUser profile, int count, int skip) =>
this.GameProfileCommentsIncluded
public DatabaseList<GameProfileComment> GetProfileComments(GameUser profile, int count, int skip) =>
new(this.GameProfileCommentsIncluded
.Where(c => c.Profile == profile)
.OrderByDescending(c => c.Timestamp)
.AsEnumerableIfRealm()
.Skip(skip)
.Take(count);
.OrderByDescending(c => c.Timestamp), skip, count);

[Pure]
public int GetTotalCommentsForProfile(GameUser profile) => this.GameProfileComments.Count(c => c.Profile == profile);
Expand Down Expand Up @@ -80,13 +77,10 @@ public GameLevelComment PostCommentToLevel(GameLevel level, GameUser author, str
return comment;
}

public IEnumerable<GameLevelComment> GetLevelComments(GameLevel level, int count, int skip) =>
this.GameLevelCommentsIncluded
public DatabaseList<GameLevelComment> GetLevelComments(GameLevel level, int count, int skip) =>
new(this.GameLevelCommentsIncluded
.Where(c => c.Level == level)
.OrderByDescending(c => c.Timestamp)
.AsEnumerableIfRealm()
.Skip(skip)
.Take(count);
.OrderByDescending(c => c.Timestamp), skip, count);

[Pure]
public int GetTotalCommentsForLevel(GameLevel level) => this.GameLevelComments.Count(c => c.Level == level);
Expand Down
31 changes: 19 additions & 12 deletions Refresh.Database/GameDatabaseContext.Relations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -672,17 +672,16 @@ public int GetTotalRatingsForProfileComment(GameProfileComment comment, RatingTy
public int GetTotalRatingsForLevelComment(GameLevelComment comment, RatingType type) =>
this.LevelCommentRelations.Count(r => r.Comment == comment && r.RatingType == type);

private bool RateComment<TComment, TCommentRelation>(GameUser user, TComment comment, RatingType ratingType, DbSet<TCommentRelation> list)
private void RateComment<TComment, TCommentRelation>(GameUser user, TComment comment, RatingType ratingType, DbSet<TCommentRelation> list)
where TComment : class, IGameComment
where TCommentRelation : class, ICommentRelation<TComment>, new()
{
if (ratingType == RatingType.Neutral)
return false;

TCommentRelation? relation = list.FirstOrDefault(r => r.Comment == comment && r.User == user);

if (relation == null)
{
if (ratingType == RatingType.Neutral) return;

relation = new TCommentRelation
{
User = user,
Expand All @@ -698,20 +697,28 @@ private bool RateComment<TComment, TCommentRelation>(GameUser user, TComment com
}
else
{
this.Write(() =>
if (ratingType == RatingType.Neutral)
{
relation.Timestamp = this._time.Now;
relation.RatingType = ratingType;
});
this.Write(() =>
{
list.Remove(relation);
});
}
else
{
this.Write(() =>
{
relation.Timestamp = this._time.Now;
relation.RatingType = ratingType;
});
}
}

return true;
}

public bool RateProfileComment(GameUser user, GameProfileComment comment, RatingType ratingType)
public void RateProfileComment(GameUser user, GameProfileComment comment, RatingType ratingType)
=> this.RateComment(user, comment, ratingType, this.ProfileCommentRelations);

public bool RateLevelComment(GameUser user, GameLevelComment comment, RatingType ratingType)
public void RateLevelComment(GameUser user, GameLevelComment comment, RatingType ratingType)
=> this.RateComment(user, comment, ratingType, this.LevelCommentRelations);

#endregion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@ public class ApiValidationError : ApiError

public const string NumberParseErrorWhen = "The number could not be parsed by the server";
public static readonly ApiValidationError NumberParseError = new(NumberParseErrorWhen);

public const string RatingParseErrorWhen = "The given rating could not be parsed by the server";
public static readonly ApiValidationError RatingParseError = new(RatingParseErrorWhen);

public const string IpAddressParseErrorWhen = "The IP address could not be parsed by the server";
public static readonly ApiValidationError IpAddressParseError = new(IpAddressParseErrorWhen);

public const string NoPhotoDeletionPermissionErrorWhen = "You do not have permission to delete someone else's photo";
public static readonly ApiValidationError NoPhotoDeletionPermissionError = new(NoPhotoDeletionPermissionErrorWhen);

public const string NoCommentDeletionPermissionErrorWhen = "You do not have permission to delete this comment";
public static readonly ApiValidationError NoCommentDeletionPermissionError = new(NoCommentDeletionPermissionErrorWhen);

public const string HashInvalidErrorWhen = "The hash is invalid (should be SHA1 hash)";
public static readonly ApiValidationError HashInvalidError = new(HashInvalidErrorWhen);
Expand Down
170 changes: 170 additions & 0 deletions Refresh.Interfaces.APIv3/Endpoints/CommentApiEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using AttribDoc.Attributes;
using Bunkum.Core;
using Bunkum.Core.Endpoints;
using Bunkum.Protocols.Http;
using Refresh.Core.Types.Data;
using Refresh.Database;
using Refresh.Database.Models.Comments;
using Refresh.Database.Models.Levels;
using Refresh.Database.Models.Users;
using Refresh.Interfaces.APIv3.Documentation.Attributes;
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes;
using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Comments;
using Refresh.Interfaces.APIv3.Extensions;

namespace Refresh.Interfaces.APIv3.Endpoints;

public class CommentApiEndpoints : EndpointGroup
{
#region Profile
[ApiV3Endpoint("users/uuid/{uuid}/comments"), Authentication(false)]
[DocSummary("Gets comments posted under the specified user's profile.")]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)]
[DocUsesPageData]
public ApiListResponse<ApiProfileCommentResponse> GetCommentsOnProfile(RequestContext context, DataContext dataContext, string uuid)
{
GameUser? profile = dataContext.Database.GetUserByUuid(uuid);
if (profile == null) return ApiNotFoundError.UserMissingError;

(int skip, int count) = context.GetPageData();

DatabaseList<GameProfileComment> comments = dataContext.Database.GetProfileComments(profile, count, skip);
return DatabaseListExtensions.FromOldList<ApiProfileCommentResponse, GameProfileComment>(comments, dataContext);
}

[ApiV3Endpoint("users/uuid/{uuid}/comments", HttpMethods.Post)]
[DocSummary("Posts the given comment under the specified user's profile.")]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)]
public ApiResponse<ApiProfileCommentResponse> PostCommentOnProfile(RequestContext context,
DataContext dataContext, string uuid, GameUser user, ApiCommentPostRequest body)
{
GameUser? profile = dataContext.Database.GetUserByUuid(uuid);
if (profile == null) return ApiNotFoundError.UserMissingError;

GameProfileComment comment = dataContext.Database.PostCommentToProfile(profile, user, body.Content);
return ApiProfileCommentResponse.FromOld(comment, dataContext);
}

[ApiV3Endpoint("profileComments/id/{id}"), Authentication(false)]
[DocSummary("Gets the profile comment specified by its ID.")]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.CommentMissingErrorWhen)]
public ApiResponse<ApiProfileCommentResponse> GetProfileComment(RequestContext context, DataContext dataContext, int id)
{
GameProfileComment? comment = dataContext.Database.GetProfileCommentById(id);
if (comment == null) return ApiNotFoundError.CommentMissingError;

return ApiProfileCommentResponse.FromOld(comment, dataContext);
}

[ApiV3Endpoint("profileComments/id/{id}", HttpMethods.Delete)]
[DocSummary("Deletes the profile comment specified by its ID. Fails if the user is not the comment poster or the profile owner.")]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.CommentMissingErrorWhen)]
[DocError(typeof(ApiValidationError), ApiValidationError.NoCommentDeletionPermissionErrorWhen)]
public ApiOkResponse DeleteProfileComment(RequestContext context, DataContext dataContext, GameUser user, int id)
{
GameProfileComment? comment = dataContext.Database.GetProfileCommentById(id);
if (comment == null) return ApiNotFoundError.CommentMissingError;

if (user != comment.Author && user != comment.Profile) return ApiValidationError.NoCommentDeletionPermissionError;

dataContext.Database.DeleteProfileComment(comment);
return new ApiOkResponse();
}

[ApiV3Endpoint("profileComments/id/{id}/rate/{rawRating}", HttpMethods.Post)]
[DocSummary("Rates the profile comment specified by its ID.")]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.CommentMissingErrorWhen)]
[DocError(typeof(ApiValidationError), ApiValidationError.RatingParseErrorWhen)]
public ApiOkResponse RateProfileComment(RequestContext context, DataContext dataContext, GameUser user, int id,
[DocSummary("The user's new rating for the comment. -1 = dislike, 0 = neutral, 1 = like.")] string rawRating)
{
GameProfileComment? comment = dataContext.Database.GetProfileCommentById(id);
if (comment == null) return ApiNotFoundError.CommentMissingError;

// rawRating is string and not sbyte or integer because passing any out of range value will make Bunkum
// set rawRating to 0 instead, which we would here wrongly take as a neutral rating instead of an invalid value.
Comment thread
jvyden marked this conversation as resolved.
if (!sbyte.TryParse(rawRating, out sbyte rating) || !Enum.IsDefined(typeof(RatingType), rating))
return ApiValidationError.RatingParseError;

dataContext.Database.RateProfileComment(user, comment, (RatingType)rating);
return new ApiOkResponse();
}
#endregion

#region Level
[ApiV3Endpoint("levels/id/{id}/comments"), Authentication(false)]
[DocSummary("Gets comments posted under the specified level.")]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.LevelMissingErrorWhen)]
[DocUsesPageData]
public ApiListResponse<ApiLevelCommentResponse> GetCommentsOnLevel(RequestContext context, DataContext dataContext, int id)
{
GameLevel? level = dataContext.Database.GetLevelById(id);
if (level == null) return ApiNotFoundError.LevelMissingError;

(int skip, int count) = context.GetPageData();

DatabaseList<GameLevelComment> comments = dataContext.Database.GetLevelComments(level, count, skip);
return DatabaseListExtensions.FromOldList<ApiLevelCommentResponse, GameLevelComment>(comments, dataContext);
}

[ApiV3Endpoint("levels/id/{id}/comments", HttpMethods.Post)]
[DocSummary("Posts the given comment under the specified level.")]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.LevelMissingErrorWhen)]
public ApiResponse<ApiLevelCommentResponse> PostCommentOnLevel(RequestContext context,
DataContext dataContext, int id, GameUser user, ApiCommentPostRequest body)
{
GameLevel? level = dataContext.Database.GetLevelById(id);
if (level == null) return ApiNotFoundError.LevelMissingError;

GameLevelComment comment = dataContext.Database.PostCommentToLevel(level, user, body.Content);
return ApiLevelCommentResponse.FromOld(comment, dataContext);
}

[ApiV3Endpoint("levelComments/id/{id}"), Authentication(false)]
[DocSummary("Gets the level comment specified by its ID.")]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.CommentMissingErrorWhen)]
public ApiResponse<ApiLevelCommentResponse> GetLevelComment(RequestContext context, DataContext dataContext, int id)
{
GameLevelComment? comment = dataContext.Database.GetLevelCommentById(id);
if (comment == null) return ApiNotFoundError.CommentMissingError;

return ApiLevelCommentResponse.FromOld(comment, dataContext);
}

[ApiV3Endpoint("levelComments/id/{id}", HttpMethods.Delete)]
[DocSummary("Deletes the level comment specified by its ID. Fails if the user is not the comment poster or the level publisher.")]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.CommentMissingErrorWhen)]
[DocError(typeof(ApiValidationError), ApiValidationError.NoCommentDeletionPermissionErrorWhen)]
public ApiOkResponse DeleteLevelComment(RequestContext context, DataContext dataContext, GameUser user, int id)
{
GameLevelComment? comment = dataContext.Database.GetLevelCommentById(id);
if (comment == null) return ApiNotFoundError.CommentMissingError;

if (user != comment.Author && user != comment.Level.Publisher) return ApiValidationError.NoCommentDeletionPermissionError;

dataContext.Database.DeleteLevelComment(comment);
return new ApiOkResponse();
}

[ApiV3Endpoint("levelComments/id/{id}/rate/{rawRating}", HttpMethods.Post)]
[DocSummary("Rates the level comment specified by its ID.")]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.CommentMissingErrorWhen)]
[DocError(typeof(ApiValidationError), ApiValidationError.RatingParseErrorWhen)]
public ApiOkResponse RateLevelComment(RequestContext context, DataContext dataContext, GameUser user, int id,
[DocSummary("The user's new rating for the comment. -1 = dislike, 0 = neutral, 1 = like.")] string rawRating)
{
GameLevelComment? comment = dataContext.Database.GetLevelCommentById(id);
if (comment == null) return ApiNotFoundError.CommentMissingError;

// rawRating is string and not sbyte or integer because passing any out of range value will make Bunkum
// set rawRating to 0 instead, which we would here wrongly take as a neutral rating instead of an invalid value.
if (!sbyte.TryParse(rawRating, out sbyte rating) || !Enum.IsDefined(typeof(RatingType), rating))
return ApiValidationError.RatingParseError;

dataContext.Database.RateLevelComment(user, comment, (RatingType)rating);
return new ApiOkResponse();
}
#endregion
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request;

[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
public class ApiCommentPostRequest
{
public required string Content { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Refresh.Core.Types.Data;
using Refresh.Database.Models.Comments;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Data;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;

namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Comments;

[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
public class ApiLevelCommentResponse : IApiResponse, IDataConvertableFrom<ApiLevelCommentResponse, GameLevelComment>
{
public required int CommentId { get; set; }
public required string Content { get; set; }
public required ApiMinimalUserResponse Publisher { get; set; }
public required ApiMinimalLevelResponse Level { get; set; }
public required ApiRatingResponse Rating { get; set; }
public required DateTimeOffset Timestamp { get; set; }

public static ApiLevelCommentResponse? FromOld(GameLevelComment? old, DataContext dataContext)
{
if (old == null) return null;

return new ApiLevelCommentResponse
{
CommentId = old.SequentialId,
Content = old.Content,
Publisher = ApiMinimalUserResponse.FromOld(old.Author, dataContext)!,
Level = ApiMinimalLevelResponse.FromOld(old.Level, dataContext)!,
Rating = ApiRatingResponse.FromRating
(
dataContext.Database.GetTotalRatingsForLevelComment(old, RatingType.Yay),
dataContext.Database.GetTotalRatingsForLevelComment(old, RatingType.Boo),
dataContext.User != null ? (int?)dataContext.Database.GetLevelCommentRatingByUser(old, dataContext.User) : 0
),
Timestamp = old.Timestamp,
};
}

public static IEnumerable<ApiLevelCommentResponse> FromOldList(IEnumerable<GameLevelComment> oldList, DataContext dataContext)
=> oldList.Select(old => FromOld(old, dataContext)).ToList()!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Refresh.Core.Types.Data;
using Refresh.Database.Models.Comments;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Data;
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;

namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Comments;

[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
public class ApiProfileCommentResponse : IApiResponse, IDataConvertableFrom<ApiProfileCommentResponse, GameProfileComment>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ditto concerns for this class as well

{
public required int CommentId { get; set; }
public required string Content { get; set; }
public required ApiMinimalUserResponse Publisher { get; set; }
public required ApiMinimalUserResponse Profile { get; set; }
public required ApiRatingResponse Rating { get; set; }
public required DateTimeOffset Timestamp { get; set; }

public static ApiProfileCommentResponse? FromOld(GameProfileComment? old, DataContext dataContext)
{
if (old == null) return null;

return new ApiProfileCommentResponse
{
CommentId = old.SequentialId,
Content = old.Content,
Publisher = ApiMinimalUserResponse.FromOld(old.Author, dataContext)!,
Profile = ApiMinimalUserResponse.FromOld(old.Profile, dataContext)!,
Rating = ApiRatingResponse.FromRating
(
dataContext.Database.GetTotalRatingsForProfileComment(old, RatingType.Yay),
dataContext.Database.GetTotalRatingsForProfileComment(old, RatingType.Boo),
dataContext.User != null ? (int?)dataContext.Database.GetProfileCommentRatingByUser(old, dataContext.User) : 0
),
Timestamp = old.Timestamp,
};
}

public static IEnumerable<ApiProfileCommentResponse> FromOldList(IEnumerable<GameProfileComment> oldList, DataContext dataContext)
=> oldList.Select(old => FromOld(old, dataContext)).ToList()!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Data;

[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
public class ApiRatingResponse : IApiResponse
{
public required int YayRatings { get; set; }
public required int BooRatings { get; set; }
public required int OwnRating { get; set; }

public static ApiRatingResponse FromRating(int yayRatings, int booRatings, int? ownRating)
{
return new()
{
YayRatings = yayRatings,
BooRatings = booRatings,
OwnRating = ownRating ?? 0,
};
}
}
Loading