Add synced user notes
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using QuestionsApi.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class UserNotepadTests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidatorAcceptsPlainTextAtMaximumLength()
|
||||
{
|
||||
var request = new UpdateUserNotepadRequest
|
||||
{
|
||||
Content = new string('x', UserNotepadValidator.MaxContentLength),
|
||||
ExpectedRevision = 0
|
||||
};
|
||||
|
||||
Assert.Null(UserNotepadValidator.Validate(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatorRejectsOversizedContentAndNegativeRevision()
|
||||
{
|
||||
var oversized = new UpdateUserNotepadRequest
|
||||
{
|
||||
Content = new string('x', UserNotepadValidator.MaxContentLength + 1),
|
||||
ExpectedRevision = 0
|
||||
};
|
||||
Assert.Contains("characters or fewer", UserNotepadValidator.Validate(oversized));
|
||||
|
||||
var invalidRevision = new UpdateUserNotepadRequest { ExpectedRevision = -1 };
|
||||
Assert.Contains("cannot be negative", UserNotepadValidator.Validate(invalidRevision));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponseDoesNotExposeUserOrMongoIdentifiers()
|
||||
{
|
||||
var response = UserNotepadResponse.FromModel(new UserNotepad
|
||||
{
|
||||
UserId = "private-user-id",
|
||||
Content = "My notes",
|
||||
Revision = 7
|
||||
});
|
||||
|
||||
Assert.Equal("My notes", response.Content);
|
||||
Assert.Equal(7, response.Revision);
|
||||
Assert.Null(typeof(UserNotepadResponse).GetProperty(nameof(UserNotepad.UserId)));
|
||||
Assert.Null(typeof(UserNotepadResponse).GetProperty(nameof(UserNotepad.Id)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController, Route("api/notes"), Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class NotesController(UserNotepadStore notes) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<UserNotepadResponse>> Get()
|
||||
{
|
||||
var note = await notes.GetAsync(UserId());
|
||||
return Ok(note is null ? UserNotepadResponse.Empty() : UserNotepadResponse.FromModel(note));
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task<ActionResult<UserNotepadResponse>> Update(UpdateUserNotepadRequest request)
|
||||
{
|
||||
if (UserNotepadValidator.Validate(request) is { } error) return BadRequest(error);
|
||||
var result = await notes.UpdateAsync(UserId(), request.Content ?? "", request.ExpectedRevision);
|
||||
if (result.Succeeded) return Ok(UserNotepadResponse.FromModel(result.Note!));
|
||||
var current = result.Current is null
|
||||
? UserNotepadResponse.Empty()
|
||||
: UserNotepadResponse.FromModel(result.Current);
|
||||
return Conflict(current);
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? "";
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public sealed class UserNotepad
|
||||
{
|
||||
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public string Content { get; set; } = "";
|
||||
public long Revision { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class UpdateUserNotepadRequest
|
||||
{
|
||||
public string Content { get; set; } = "";
|
||||
public long ExpectedRevision { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UserNotepadResponse
|
||||
{
|
||||
public string Content { get; set; } = "";
|
||||
public long Revision { get; set; }
|
||||
public DateTime? UpdatedUtc { get; set; }
|
||||
|
||||
public static UserNotepadResponse Empty() => new();
|
||||
|
||||
public static UserNotepadResponse FromModel(UserNotepad note) => new()
|
||||
{
|
||||
Content = note.Content,
|
||||
Revision = note.Revision,
|
||||
UpdatedUtc = note.UpdatedUtc
|
||||
};
|
||||
}
|
||||
|
||||
public static class UserNotepadValidator
|
||||
{
|
||||
public const int MaxContentLength = 50_000;
|
||||
|
||||
public static string? Validate(UpdateUserNotepadRequest request)
|
||||
{
|
||||
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
|
||||
if ((request.Content ?? "").Length > MaxContentLength)
|
||||
return $"content must be {MaxContentLength} characters or fewer";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ builder.Services.AddSingleton<CcsStore>();
|
||||
builder.Services.AddSingleton<CcsRatingStore>();
|
||||
builder.Services.AddSingleton<CcsCaseReportStore>();
|
||||
builder.Services.AddSingleton<AppFeedbackStore>();
|
||||
builder.Services.AddSingleton<UserNotepadStore>();
|
||||
builder.Services.AddSingleton<ServiceMetrics>();
|
||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed record UserNotepadUpdateResult(UserNotepad? Note, UserNotepad? Current)
|
||||
{
|
||||
public bool Succeeded => Note is not null;
|
||||
}
|
||||
|
||||
public sealed class UserNotepadStore
|
||||
{
|
||||
private readonly IMongoCollection<UserNotepad> _notes;
|
||||
|
||||
public UserNotepadStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
|
||||
_notes.Indexes.CreateOne(new CreateIndexModel<UserNotepad>(
|
||||
Builders<UserNotepad>.IndexKeys.Ascending(note => note.UserId),
|
||||
new CreateIndexOptions { Unique = true, Name = "user_notepad_unique_user" }));
|
||||
}
|
||||
|
||||
public async Task<UserNotepad?> GetAsync(string userId) =>
|
||||
await _notes.Find(note => note.UserId == userId).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<UserNotepadUpdateResult> UpdateAsync(
|
||||
string userId,
|
||||
string content,
|
||||
long expectedRevision)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (expectedRevision == 0)
|
||||
{
|
||||
var created = new UserNotepad
|
||||
{
|
||||
UserId = userId,
|
||||
Content = content,
|
||||
Revision = 1,
|
||||
CreatedUtc = now,
|
||||
UpdatedUtc = now
|
||||
};
|
||||
try
|
||||
{
|
||||
await _notes.InsertOneAsync(created);
|
||||
return new UserNotepadUpdateResult(created, null);
|
||||
}
|
||||
catch (MongoWriteException exception) when (
|
||||
exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return new UserNotepadUpdateResult(null, await GetAsync(userId));
|
||||
}
|
||||
}
|
||||
|
||||
var updated = await _notes.FindOneAndUpdateAsync(
|
||||
note => note.UserId == userId && note.Revision == expectedRevision,
|
||||
Builders<UserNotepad>.Update
|
||||
.Set(note => note.Content, content)
|
||||
.Set(note => note.UpdatedUtc, now)
|
||||
.Inc(note => note.Revision, 1),
|
||||
new FindOneAndUpdateOptions<UserNotepad> { ReturnDocument = ReturnDocument.After });
|
||||
|
||||
return updated is not null
|
||||
? new UserNotepadUpdateResult(updated, null)
|
||||
: new UserNotepadUpdateResult(null, await GetAsync(userId));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user