Skip to content

Commit a02fa21

Browse files
authored
Implement APIv3 Review endpoints, improve review submission (#966)
This PR adds API endpoints for reviews, tests for those endpoints (which singlehandedly take up over half the added lines), and reworks a few review DB methods (for example, `AddReviewToLevel()` now takes a `ISubmitReviewRequest` instead of `GameReview`, and review rating won't create relations with neutral rating anymore)
2 parents 8db9f7a + ae24425 commit a02fa21

File tree

15 files changed

+963
-123
lines changed

15 files changed

+963
-123
lines changed

Refresh.Database/GameDatabaseContext.Relations.cs

Lines changed: 69 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -285,42 +285,44 @@ public void ClearQueue(GameUser user)
285285

286286
public void RateReview(GameReview review, RatingType ratingType, GameUser user)
287287
{
288-
// If the rating type is neutral, remove the previous review rating by user
289-
if (ratingType == RatingType.Neutral && this.ReviewRatingExistsByUser(user, review))
290-
{
291-
RateReviewRelation relation =
292-
this.RateReviewRelations.First(r => r.Review == review && r.User == user);
293-
this.Write(() => this.RateReviewRelations.Remove(relation));
288+
RateReviewRelation? relation = this.GetRateReviewRelationForReview(user, review);
294289

295-
return;
296-
}
297-
298-
// If the relation already exists, set the new rating
299-
if (this.ReviewRatingExistsByUser(user, review))
290+
if (relation != null)
300291
{
301-
RateReviewRelation relation = this.RateReviewRelations.First(r => r.Review == review && r.User == user);
302-
303-
this.Write(() =>
292+
// If the rating type is neutral, remove the previous review rating by user
293+
if (ratingType == RatingType.Neutral)
294+
{
295+
this.RateReviewRelations.Remove(relation);
296+
297+
}
298+
// If the relation already exists, set the new rating
299+
else
304300
{
305301
relation.RatingType = ratingType;
306302
relation.Timestamp = this._time.Now;
307-
});
308-
309-
return;
303+
}
310304
}
311-
312-
RateReviewRelation reviewRelation = new()
305+
// Create a new relation if there is none yet
306+
else if (ratingType != RatingType.Neutral)
313307
{
314-
RatingType = ratingType,
315-
Review = review,
316-
User = user,
317-
Timestamp = this._time.Now,
318-
};
308+
relation = new()
309+
{
310+
RatingType = ratingType,
311+
Review = review,
312+
User = user,
313+
Timestamp = this._time.Now,
314+
};
319315

320-
this.Write(() =>
316+
this.RateReviewRelations.Add(relation);
317+
}
318+
// Don't create a new relation if it would be neutral anyway
319+
else
321320
{
322-
this.RateReviewRelations.Add(reviewRelation);
323-
});
321+
return;
322+
}
323+
324+
this.SaveChanges();
325+
return;
324326
}
325327

326328
public DatabaseRating GetRatingForReview(GameReview review)
@@ -356,7 +358,7 @@ public int GetRawRatingForReview(GameReview review)
356358
}
357359

358360
public GameReview? GetReviewByUserForLevel(GameUser user, GameLevel level)
359-
=> this.GameReviewsIncluded.FirstOrDefault(gameReview => gameReview.Publisher == user && gameReview.Level == level);
361+
=> this.GameReviewsIncluded.FirstOrDefault(r => r.PublisherUserId == user.UserId && r.LevelId == level.LevelId);
360362

361363
public GameReview? GetReviewById(int reviewId)
362364
=> this.GameReviewsIncluded.FirstOrDefault(gameReview => gameReview.ReviewId == reviewId);
@@ -365,7 +367,7 @@ public bool ReviewRatingExistsByUser(GameUser user, GameReview review)
365367
=> this.RateReviewRelations.Any(relation => relation.Review == review && relation.User == user);
366368

367369
public RateReviewRelation? GetRateReviewRelationForReview(GameUser user, GameReview review)
368-
=> this.RateReviewRelationsIncluded.FirstOrDefault(relation => relation.User == user && relation.Review == review);
370+
=> this.RateReviewRelationsIncluded.FirstOrDefault(r => r.UserId == user.UserId && r.ReviewId == review.ReviewId);
369371

370372
public bool ReviewRatingExists(GameUser user, GameReview review, RatingType rating)
371373
=> this.RateReviewRelations.Any(r => r.Review == review && r.User == user && r.RatingType == rating);
@@ -387,9 +389,6 @@ public bool ReviewRatingExists(GameUser user, GameReview review, RatingType rati
387389

388390
public bool RateLevel(GameLevel level, GameUser user, RatingType type)
389391
{
390-
if (level.Publisher?.UserId == user.UserId) return false;
391-
if (level.GameVersion != TokenGame.LittleBigPlanetPSP && !this.HasUserPlayedLevel(level, user)) return false;
392-
393392
RateLevelRelation? rating = this.GetRateRelationByUser(level, user);
394393

395394
if (rating == null)
@@ -429,36 +428,52 @@ public int GetTotalRatingsForLevel(GameLevel level, RatingType type, bool includ
429428
/// <summary>
430429
/// Adds a review to the database, deleting any old ones by the user on that level.
431430
/// </summary>
432-
/// <param name="review">The review to add</param>
431+
/// <param name="createInfo">Review attributes like text content and labels</param>
433432
/// <param name="level">The level the review is for</param>
434433
/// <param name="user">The user who made the review</param>
435-
public void AddReviewToLevel(GameReview review, GameLevel level)
434+
public GameReview AddReviewToLevel(ISubmitReviewRequest createInfo, GameLevel level, GameUser user)
436435
{
437-
Debug.Assert(review.Publisher != null);
438-
439-
List<GameReview> toRemove = this.GameReviews
440-
.Where(r => r.Publisher == review.Publisher)
441-
.Where(r => r.Level == level)
442-
.ToList();
443-
if (toRemove.Count > 0)
436+
DateTimeOffset now = this._time.Now;
437+
GameReview? review = this.GetReviewByLevelAndUser(level, user);
438+
439+
if (review == null)
444440
{
445-
this.WriteEnsuringStatistics(review.Publisher, level, () =>
441+
review = new()
446442
{
447-
foreach (GameReview reviewToDelete in toRemove)
448-
{
449-
this.GameReviews.Remove(reviewToDelete);
450-
level.Statistics!.ReviewCount--;
451-
review.Publisher.Statistics!.ReviewCount--;
452-
}
443+
Level = level,
444+
Publisher = user,
445+
PostedAt = now,
446+
UpdatedAt = now,
447+
Labels = createInfo.Labels ?? [],
448+
Content = createInfo.Content ?? "",
449+
};
450+
451+
this.WriteEnsuringStatistics(user, level, () =>
452+
{
453+
this.GameReviews.Add(review);
454+
455+
level.Statistics!.ReviewCount++;
456+
user.Statistics!.ReviewCount++;
453457
});
454458
}
455-
456-
this.WriteEnsuringStatistics(review.Publisher, level, () =>
459+
else
457460
{
458-
this.GameReviews.Add(review);
459-
level.Statistics!.ReviewCount++;
460-
review.Publisher.Statistics!.ReviewCount++;
461-
});
461+
review = this.UpdateReview(createInfo, review);
462+
}
463+
464+
return review;
465+
}
466+
467+
public GameReview UpdateReview(ISubmitReviewRequest updateInfo, GameReview review)
468+
{
469+
DateTimeOffset now = this._time.Now;
470+
471+
review.Labels = updateInfo.Labels ?? review.Labels;
472+
review.Content = updateInfo.Content ?? review.Content;
473+
review.UpdatedAt = now;
474+
475+
this.SaveChanges();
476+
return review;
462477
}
463478

464479
public void MigrateReviewLabels(IEnumerable<GameReview> reviews)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using Microsoft.EntityFrameworkCore.Infrastructure;
2+
using Microsoft.EntityFrameworkCore.Migrations;
3+
4+
#nullable disable
5+
6+
namespace Refresh.Database.Migrations
7+
{
8+
/// <inheritdoc />
9+
[DbContext(typeof(GameDatabaseContext))]
10+
[Migration("20251012133948_TrackReviewUpdateDates")]
11+
public partial class TrackReviewUpdateDates : Migration
12+
{
13+
/// <inheritdoc />
14+
protected override void Up(MigrationBuilder migrationBuilder)
15+
{
16+
migrationBuilder.AddColumn<DateTimeOffset>(
17+
name: "UpdatedAt",
18+
table: "GameReviews",
19+
type: "timestamp with time zone",
20+
nullable: true);
21+
22+
migrationBuilder.Sql("UPDATE \"GameReviews\" SET \"UpdatedAt\" = \"PostedAt\"");
23+
24+
// PostedAt is NOT NULL, so there shouldn't be any issues doing this
25+
migrationBuilder.AlterColumn<DateTimeOffset>(
26+
name: "UpdatedAt",
27+
table: "GameReviews",
28+
nullable: false,
29+
oldNullable: true);
30+
}
31+
32+
/// <inheritdoc />
33+
protected override void Down(MigrationBuilder migrationBuilder)
34+
{
35+
migrationBuilder.DropColumn(
36+
name: "UpdatedAt",
37+
table: "GameReviews");
38+
}
39+
}
40+
}

Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
232232
.IsRequired()
233233
.HasColumnType("text");
234234

235+
b.Property<DateTimeOffset>("UpdatedAt")
236+
.HasColumnType("timestamp with time zone");
237+
235238
b.HasKey("ReviewId");
236239

237240
b.HasIndex("LevelId");

Refresh.Database/Models/Comments/GameReview.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Refresh.Database.Models.Users;
22
using Refresh.Database.Models.Levels;
3+
using MongoDB.Bson;
34

45
namespace Refresh.Database.Models.Comments;
56

@@ -12,12 +13,13 @@ public partial class GameReview : ISequentialId
1213
[Required, ForeignKey(nameof(LevelId))] public GameLevel Level { get; set; }
1314
[Required] public int LevelId { get; set; }
1415

15-
[Required]
16-
public GameUser Publisher { get; set; }
16+
[Required, ForeignKey(nameof(PublisherUserId))] public GameUser Publisher { get; set; }
17+
[Required] public ObjectId PublisherUserId { get; set; }
1718

1819
#nullable enable
1920

2021
public DateTimeOffset PostedAt { get; set; }
22+
public DateTimeOffset UpdatedAt { get; set; }
2123

2224
[Obsolete("Deprecated. This attribute only exists so BackfillReviewLabelsMigration could properly migrate labels at runtime.")]
2325
public string? LabelsString { get; set; }
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using Refresh.Database.Models.Levels;
2+
3+
namespace Refresh.Database.Query;
4+
5+
public interface ISubmitReviewRequest
6+
{
7+
public List<Label>? Labels { get; set; }
8+
public string? Content { get; set; }
9+
}

Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ public class ApiValidationError : ApiError
1111
public const string NumberParseErrorWhen = "The number could not be parsed by the server";
1212
public static readonly ApiValidationError NumberParseError = new(NumberParseErrorWhen);
1313

14-
public const string RatingParseErrorWhen = "The given rating could not be parsed by the server";
14+
public const string RatingParseErrorWhen = "The given rating was invalid";
1515
public static readonly ApiValidationError RatingParseError = new(RatingParseErrorWhen);
16-
16+
1717
public const string IpAddressParseErrorWhen = "The IP address could not be parsed by the server";
1818
public static readonly ApiValidationError IpAddressParseError = new(IpAddressParseErrorWhen);
1919

@@ -22,6 +22,24 @@ public class ApiValidationError : ApiError
2222

2323
public const string NoCommentDeletionPermissionErrorWhen = "You do not have permission to delete this comment";
2424
public static readonly ApiValidationError NoCommentDeletionPermissionError = new(NoCommentDeletionPermissionErrorWhen);
25+
26+
public const string NoReviewEditPermissionErrorWhen = "You do not have permission to edit this review";
27+
public static readonly ApiValidationError NoReviewEditPermissionError = new(NoReviewEditPermissionErrorWhen);
28+
29+
public const string NoReviewDeletionPermissionErrorWhen = "You do not have permission to delete this review";
30+
public static readonly ApiValidationError NoReviewDeletionPermissionError = new(NoReviewDeletionPermissionErrorWhen);
31+
32+
public const string DontRateOwnContentWhen = "You may not rate your own content";
33+
public static readonly ApiValidationError DontRateOwnContent = new(DontRateOwnContentWhen);
34+
35+
public const string DontReviewOwnLevelWhen = "You may not review your own level";
36+
public static readonly ApiValidationError DontReviewOwnLevel = new(DontReviewOwnLevelWhen);
37+
38+
public const string DontReviewLevelBeforePlayingWhen = "You may not review levels you haven't played yet";
39+
public static readonly ApiValidationError DontReviewLevelBeforePlaying = new(DontReviewLevelBeforePlayingWhen);
40+
41+
public const string ReviewHasInvalidLabelsWhen = "Your review contained invalid labels";
42+
public static readonly ApiValidationError ReviewHasInvalidLabels = new(ReviewHasInvalidLabelsWhen);
2543

2644
public const string HashInvalidErrorWhen = "The hash is invalid (should be SHA1 hash)";
2745
public static readonly ApiValidationError HashInvalidError = new(HashInvalidErrorWhen);
@@ -46,6 +64,9 @@ public class ApiValidationError : ApiError
4664

4765
public const string EmailDoesNotActuallyExistErrorWhen = "The email address given does not exist. Are you sure you typed it in correctly?";
4866
public static readonly ApiValidationError EmailDoesNotActuallyExistError = new(EmailDoesNotActuallyExistErrorWhen);
67+
68+
public const string BadUserLookupIdTypeWhen = "The ID type used to specify the user is not supported";
69+
public static readonly ApiValidationError BadUserLookupIdType = new(BadUserLookupIdTypeWhen);
4970

5071
public ApiValidationError(string message) : base(message) {}
5172
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using Refresh.Database.Models.Comments;
2+
using Refresh.Database.Models.Levels;
3+
using Refresh.Database.Query;
4+
5+
namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request;
6+
7+
[JsonObject(NamingStrategyType = typeof(CamelCaseNamingStrategy))]
8+
public class ApiSubmitReviewRequest : ISubmitReviewRequest
9+
{
10+
public RatingType? LevelRating { get; set; }
11+
public List<Label>? Labels { get; set; }
12+
public string? Content { get; set; }
13+
}

Refresh.Interfaces.APIv3/Endpoints/DataTypes/Response/Levels/ApiGameReviewResponse.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using Refresh.Core.Types.Data;
2+
using Refresh.Database.Models;
23
using Refresh.Database.Models.Comments;
34
using Refresh.Database.Models.Levels;
5+
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Data;
46
using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Users;
57

68
namespace Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response.Levels;
@@ -15,9 +17,15 @@ public class ApiGameReviewResponse : IApiResponse, IDataConvertableFrom<ApiGameR
1517
public required List<Label> LabelList { get; set; }
1618
public required string Labels { get; set; } // TODO: remove this and rename LabelList to Labels in APIv4
1719
public required string Text { get; set; }
20+
public required int LevelRating { get; set; }
21+
public required ApiRatingResponse ReviewRating { get; set; }
22+
1823
public static ApiGameReviewResponse? FromOld(GameReview? old, DataContext dataContext)
1924
{
2025
if (old == null) return null;
26+
27+
DatabaseRating rating = dataContext.Database.GetRatingForReview(old);
28+
2129
return new ApiGameReviewResponse
2230
{
2331
ReviewId = old.ReviewId,
@@ -27,6 +35,15 @@ public class ApiGameReviewResponse : IApiResponse, IDataConvertableFrom<ApiGameR
2735
LabelList = old.Labels,
2836
Labels = old.Labels.ToLbpCommaList(),
2937
Text = old.Content,
38+
LevelRating = (int?)dataContext.Database.GetRatingByUser(old.Level, old.Publisher) ?? 0,
39+
ReviewRating = ApiRatingResponse.FromRating
40+
(
41+
rating.PositiveRating,
42+
rating.NegativeRating,
43+
dataContext.User != null
44+
? (int?)dataContext.Database.GetRateReviewRelationForReview(dataContext.User, old)?.RatingType
45+
: null
46+
)
3047
};
3148
}
3249

0 commit comments

Comments
 (0)