Skip to content

Commit 8d77ead

Browse files
authored
Merge pull request #1035 from Chris0Jeky/paper/1018-line-for-tomorrow
Add TomorrowNote backend for per-day line-for-tomorrow storage
2 parents 7085e7c + 4c5955d commit 8d77ead

26 files changed

Lines changed: 3157 additions & 5 deletions

backend/src/Taskdeck.Api/Controllers/TodayController.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,18 @@ public class TodayController : AuthenticatedControllerBase
1919
{
2020
private readonly IStreakService _streakService;
2121
private readonly ICadenceService _cadenceService;
22+
private readonly ITomorrowNoteService _tomorrowNoteService;
2223

2324
public TodayController(
2425
IStreakService streakService,
2526
ICadenceService cadenceService,
27+
ITomorrowNoteService tomorrowNoteService,
2628
IUserContext userContext)
2729
: base(userContext)
2830
{
2931
_streakService = streakService;
3032
_cadenceService = cadenceService;
33+
_tomorrowNoteService = tomorrowNoteService;
3134
}
3235

3336
/// <summary>
@@ -102,4 +105,51 @@ public async Task<IActionResult> GetCadence(
102105

103106
return Ok(response);
104107
}
108+
109+
/// <summary>
110+
/// Gets the tomorrow note for the given date.
111+
/// The note was written the previous day and is displayed on the specified date's morning open.
112+
/// </summary>
113+
[HttpGet("tomorrow-note")]
114+
[ProducesResponseType(typeof(TomorrowNoteResponse), StatusCodes.Status200OK)]
115+
[ProducesResponseType(StatusCodes.Status204NoContent)]
116+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
117+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
118+
public async Task<IActionResult> GetTomorrowNote(
119+
[FromQuery] DateOnly date,
120+
CancellationToken cancellationToken = default)
121+
{
122+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
123+
return errorResult!;
124+
125+
var result = await _tomorrowNoteService.GetNoteAsync(userId, date, cancellationToken);
126+
if (!result.IsSuccess)
127+
return result.ToErrorActionResult();
128+
129+
if (result.Value is null)
130+
return NoContent();
131+
132+
return Ok(result.Value);
133+
}
134+
135+
/// <summary>
136+
/// Upsert the tomorrow note for the given date.
137+
/// Idempotent PUT suitable for debounced autosave from the frontend.
138+
/// </summary>
139+
[HttpPut("tomorrow-note")]
140+
[ProducesResponseType(typeof(TomorrowNoteResponse), StatusCodes.Status200OK)]
141+
[ProducesResponseType(StatusCodes.Status400BadRequest)]
142+
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
143+
public async Task<IActionResult> SaveTomorrowNote(
144+
[FromBody] SaveTomorrowNoteRequest request,
145+
CancellationToken cancellationToken = default)
146+
{
147+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
148+
return errorResult!;
149+
150+
var result = await _tomorrowNoteService.SaveNoteAsync(
151+
userId, request.Date, request.Text, cancellationToken);
152+
153+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
154+
}
105155
}

backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
6464
services.AddScoped<INotificationService, NotificationService>();
6565
services.AddScoped<IKnowledgeService, KnowledgeService>();
6666
services.AddScoped<IWorkspaceService, WorkspaceService>();
67+
services.AddScoped<ITomorrowNoteService, TomorrowNoteService>();
6768
services.AddScoped<ISearchService, SearchService>();
6869
services.AddScoped<IStarterPackManifestValidator, StarterPackManifestValidator>();
6970
services.AddScoped<IStarterPackApplyService, StarterPackApplyService>();
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace Taskdeck.Application.DTOs;
2+
3+
public sealed record TomorrowNoteResponse(
4+
Guid Id,
5+
DateOnly Date,
6+
string Text,
7+
DateTimeOffset UpdatedAt,
8+
DateTimeOffset CreatedAt);
9+
10+
public sealed record SaveTomorrowNoteRequest(
11+
DateOnly Date,
12+
string Text);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using Taskdeck.Domain.Entities;
2+
3+
namespace Taskdeck.Application.Interfaces;
4+
5+
public interface ITomorrowNoteRepository : IRepository<TomorrowNote>
6+
{
7+
Task<TomorrowNote?> GetByUserAndDateAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default);
8+
}

backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public interface IUnitOfWork
3434
IConnectorEventRepository ConnectorEvents { get; }
3535
IConnectorCredentialRepository ConnectorCredentials { get; }
3636
IProposalRevisionRepository ProposalRevisions { get; }
37+
ITomorrowNoteRepository TomorrowNotes { get; }
3738

3839
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
3940
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using Taskdeck.Application.DTOs;
2+
using Taskdeck.Domain.Common;
3+
4+
namespace Taskdeck.Application.Services;
5+
6+
public interface ITomorrowNoteService
7+
{
8+
/// <summary>
9+
/// Gets the note for a given date. The note was written on the previous day
10+
/// and is displayed on this date's morning open.
11+
/// </summary>
12+
Task<Result<TomorrowNoteResponse?>> GetNoteAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default);
13+
14+
/// <summary>
15+
/// Upsert: creates or updates the tomorrow note for the given date.
16+
/// Autosave-friendly -- idempotent for the same user+date pair.
17+
/// </summary>
18+
Task<Result<TomorrowNoteResponse>> SaveNoteAsync(Guid userId, DateOnly date, string text, CancellationToken cancellationToken = default);
19+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using Taskdeck.Application.DTOs;
2+
using Taskdeck.Application.Interfaces;
3+
using Taskdeck.Domain.Common;
4+
using Taskdeck.Domain.Entities;
5+
using Taskdeck.Domain.Exceptions;
6+
7+
namespace Taskdeck.Application.Services;
8+
9+
public class TomorrowNoteService : ITomorrowNoteService
10+
{
11+
private readonly ITomorrowNoteRepository _repository;
12+
private readonly IUnitOfWork _unitOfWork;
13+
14+
public TomorrowNoteService(ITomorrowNoteRepository repository, IUnitOfWork unitOfWork)
15+
{
16+
_repository = repository;
17+
_unitOfWork = unitOfWork;
18+
}
19+
20+
public async Task<Result<TomorrowNoteResponse?>> GetNoteAsync(
21+
Guid userId,
22+
DateOnly date,
23+
CancellationToken cancellationToken = default)
24+
{
25+
if (userId == Guid.Empty)
26+
return Result.Failure<TomorrowNoteResponse?>(ErrorCodes.ValidationError, "User ID is required");
27+
28+
var note = await _repository.GetByUserAndDateAsync(userId, date, cancellationToken);
29+
if (note is null)
30+
return Result.Success<TomorrowNoteResponse?>(null);
31+
32+
return Result.Success<TomorrowNoteResponse?>(MapToResponse(note));
33+
}
34+
35+
public async Task<Result<TomorrowNoteResponse>> SaveNoteAsync(
36+
Guid userId,
37+
DateOnly date,
38+
string text,
39+
CancellationToken cancellationToken = default)
40+
{
41+
if (userId == Guid.Empty)
42+
return Result.Failure<TomorrowNoteResponse>(ErrorCodes.ValidationError, "User ID is required");
43+
44+
if (date == default)
45+
return Result.Failure<TomorrowNoteResponse>(ErrorCodes.ValidationError, "Date is required");
46+
47+
if (text is null)
48+
return Result.Failure<TomorrowNoteResponse>(ErrorCodes.ValidationError, "Text cannot be null");
49+
50+
if (text.Length > TomorrowNote.MaxTextLength)
51+
return Result.Failure<TomorrowNoteResponse>(
52+
ErrorCodes.ValidationError,
53+
$"Text cannot exceed {TomorrowNote.MaxTextLength} characters");
54+
55+
var existing = await _repository.GetByUserAndDateAsync(userId, date, cancellationToken);
56+
if (existing is not null)
57+
{
58+
existing.UpdateText(text);
59+
await _repository.UpdateAsync(existing, cancellationToken);
60+
await _unitOfWork.SaveChangesAsync(cancellationToken);
61+
return Result.Success(MapToResponse(existing));
62+
}
63+
64+
var note = new TomorrowNote(userId, date, text);
65+
await _repository.AddAsync(note, cancellationToken);
66+
await _unitOfWork.SaveChangesAsync(cancellationToken);
67+
68+
// Race-condition recovery: if a concurrent request created a note for the
69+
// same (userId, date) between our read and write, the UnitOfWork's conflict
70+
// resolver detaches our entity and retries SaveChanges (succeeding with no-op).
71+
// The local 'note' is now phantom data -- never persisted. Re-fetch the winner
72+
// and apply last-writer-wins so the caller's text is not silently lost.
73+
var persisted = await _repository.GetByUserAndDateAsync(userId, date, cancellationToken);
74+
if (persisted is not null && persisted.Id != note.Id)
75+
{
76+
persisted.UpdateText(text);
77+
await _repository.UpdateAsync(persisted, cancellationToken);
78+
await _unitOfWork.SaveChangesAsync(cancellationToken);
79+
return Result.Success(MapToResponse(persisted));
80+
}
81+
82+
return Result.Success(MapToResponse(note));
83+
}
84+
85+
private static TomorrowNoteResponse MapToResponse(TomorrowNote note)
86+
{
87+
return new TomorrowNoteResponse(
88+
note.Id,
89+
note.Date,
90+
note.Text,
91+
note.UpdatedAt,
92+
note.CreatedAt);
93+
}
94+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using Taskdeck.Domain.Common;
2+
using Taskdeck.Domain.Exceptions;
3+
4+
namespace Taskdeck.Domain.Entities;
5+
6+
/// <summary>
7+
/// A short note written on day X intended for display on day X+1's morning open.
8+
/// Each user may have at most one note per date.
9+
/// </summary>
10+
public class TomorrowNote : Entity
11+
{
12+
public const int MaxTextLength = 500;
13+
14+
public Guid UserId { get; private set; }
15+
public DateOnly Date { get; private set; }
16+
public string Text { get; private set; } = string.Empty;
17+
18+
private TomorrowNote() : base()
19+
{
20+
}
21+
22+
public TomorrowNote(Guid userId, DateOnly date, string text) : base()
23+
{
24+
if (userId == Guid.Empty)
25+
throw new DomainException(ErrorCodes.ValidationError, "User ID cannot be empty");
26+
27+
if (date == default)
28+
throw new DomainException(ErrorCodes.ValidationError, "Date is required");
29+
30+
ValidateText(text);
31+
32+
UserId = userId;
33+
Date = date;
34+
Text = text;
35+
}
36+
37+
public void UpdateText(string text)
38+
{
39+
ValidateText(text);
40+
Text = text;
41+
Touch();
42+
}
43+
44+
private static void ValidateText(string text)
45+
{
46+
if (text is null)
47+
throw new DomainException(ErrorCodes.ValidationError, "Text cannot be null");
48+
49+
if (text.Length > MaxTextLength)
50+
throw new DomainException(
51+
ErrorCodes.ValidationError,
52+
$"Text cannot exceed {MaxTextLength} characters");
53+
}
54+
}

backend/src/Taskdeck.Infrastructure/DependencyInjection.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
7575
services.AddScoped<IFtsKnowledgeSearchService>(sp =>
7676
sp.GetRequiredService<Taskdeck.Infrastructure.Services.KnowledgeFtsSearchService>());
7777
services.AddScoped<IProposalRevisionRepository, ProposalRevisionRepository>();
78+
services.AddScoped<ITomorrowNoteRepository, TomorrowNoteRepository>();
7879

7980
// Vector index is local; hash-based in-memory embeddings are development/test
8081
// oriented and stay disabled unless explicitly opted in.

backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1657,15 +1657,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
16571657
b.Property<DateTimeOffset>("CreatedAt")
16581658
.HasColumnType("TEXT");
16591659

1660-
b.Property<int>("Decision")
1661-
.HasColumnType("INTEGER");
1662-
16631660
b.Property<DateTimeOffset>("DecidedAt")
16641661
.HasColumnType("TEXT");
16651662

16661663
b.Property<Guid>("DecidedByUserId")
16671664
.HasColumnType("TEXT");
16681665

1666+
b.Property<int>("Decision")
1667+
.HasColumnType("INTEGER");
1668+
16691669
b.Property<double>("DecisionLatencySeconds")
16701670
.HasColumnType("REAL");
16711671

@@ -1756,6 +1756,36 @@ protected override void BuildModel(ModelBuilder modelBuilder)
17561756
b.ToTable("ProposalRevisions", (string)null);
17571757
});
17581758

1759+
modelBuilder.Entity("Taskdeck.Domain.Entities.TomorrowNote", b =>
1760+
{
1761+
b.Property<Guid>("Id")
1762+
.HasColumnType("TEXT");
1763+
1764+
b.Property<DateTimeOffset>("CreatedAt")
1765+
.HasColumnType("TEXT");
1766+
1767+
b.Property<DateOnly>("Date")
1768+
.HasColumnType("TEXT");
1769+
1770+
b.Property<string>("Text")
1771+
.IsRequired()
1772+
.HasMaxLength(500)
1773+
.HasColumnType("TEXT");
1774+
1775+
b.Property<DateTimeOffset>("UpdatedAt")
1776+
.HasColumnType("TEXT");
1777+
1778+
b.Property<Guid>("UserId")
1779+
.HasColumnType("TEXT");
1780+
1781+
b.HasKey("Id");
1782+
1783+
b.HasIndex("UserId", "Date")
1784+
.IsUnique();
1785+
1786+
b.ToTable("TomorrowNotes", (string)null);
1787+
});
1788+
17591789
modelBuilder.Entity("Taskdeck.Domain.Entities.User", b =>
17601790
{
17611791
b.Property<Guid>("Id")

0 commit comments

Comments
 (0)