Add synced user notes

This commit is contained in:
hz
2026-07-18 22:22:29 -05:00
parent a232e18c46
commit d0bb271b14
22 changed files with 930 additions and 2 deletions
@@ -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
?? "";
}
+49
View File
@@ -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;
}
}
+1
View File
@@ -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));
}
}