Skip to content

Commit bb35128

Browse files
authored
Merge pull request #1037 from Chris0Jeky/paper/1017-seal-day
Add seal-day backend action for daily snapshots
2 parents c98459d + 340558b commit bb35128

25 files changed

Lines changed: 3092 additions & 22 deletions

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
namespace Taskdeck.Api.Controllers;
1010

1111
/// <summary>
12-
/// Today-view endpoints: streak data, cadence aggregation, and daily dossier data.
12+
/// Today-view endpoints: streak data, cadence aggregation, daily dossier data, tomorrow notes, and day-seal operations.
1313
/// </summary>
1414
[ApiController]
1515
[Authorize]
@@ -19,17 +19,20 @@ public class TodayController : AuthenticatedControllerBase
1919
{
2020
private readonly IStreakService _streakService;
2121
private readonly ICadenceService _cadenceService;
22+
private readonly IDailySealService _dailySealService;
2223
private readonly ITomorrowNoteService _tomorrowNoteService;
2324

2425
public TodayController(
2526
IStreakService streakService,
2627
ICadenceService cadenceService,
28+
IDailySealService dailySealService,
2729
ITomorrowNoteService tomorrowNoteService,
2830
IUserContext userContext)
2931
: base(userContext)
3032
{
3133
_streakService = streakService;
3234
_cadenceService = cadenceService;
35+
_dailySealService = dailySealService;
3336
_tomorrowNoteService = tomorrowNoteService;
3437
}
3538

@@ -106,6 +109,26 @@ public async Task<IActionResult> GetCadence(
106109
return Ok(response);
107110
}
108111

112+
[HttpPost("seal")]
113+
public async Task<IActionResult> SealDay([FromBody] SealDayRequest request, CancellationToken cancellationToken)
114+
{
115+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
116+
return errorResult!;
117+
118+
var result = await _dailySealService.SealDayAsync(userId, request.Date, cancellationToken);
119+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
120+
}
121+
122+
[HttpGet("seal")]
123+
public async Task<IActionResult> GetSealStatus([FromQuery] DateOnly date, CancellationToken cancellationToken)
124+
{
125+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
126+
return errorResult!;
127+
128+
var result = await _dailySealService.GetSealStatusAsync(userId, date, cancellationToken);
129+
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
130+
}
131+
109132
/// <summary>
110133
/// Gets the tomorrow note for the given date.
111134
/// The note was written the previous day and is displayed on the specified date's morning open.
@@ -153,3 +176,5 @@ public async Task<IActionResult> SaveTomorrowNote(
153176
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
154177
}
155178
}
179+
180+
public sealed record SealDayRequest(DateOnly Date);

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
126126
services.AddScoped<IToolExecutor, ProposeBulkMoveExecutor>();
127127
services.AddScoped<IToolExecutor, ProposeCreateColumnExecutor>();
128128

129+
services.AddScoped<IDailySealService, DailySealService>();
130+
129131
services.AddScoped<ToolExecutorRegistry>(sp =>
130132
new ToolExecutorRegistry(sp.GetServices<IToolExecutor>()));
131133
services.AddScoped<IToolStatusNotifier, SignalRToolStatusNotifier>();
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using Taskdeck.Domain.Entities;
2+
3+
namespace Taskdeck.Application.Interfaces;
4+
5+
public interface IDailySnapshotRepository : IRepository<DailySnapshot>
6+
{
7+
Task<DailySnapshot?> GetByUserAndDateAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default);
8+
Task<IReadOnlyList<DailySnapshot>> GetSealedDaysAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default);
9+
}

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+
IDailySnapshotRepository DailySnapshots { get; }
3738
ITomorrowNoteRepository TomorrowNotes { get; }
3839

3940
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using Taskdeck.Application.Interfaces;
2+
using Taskdeck.Domain.Common;
3+
using Taskdeck.Domain.Entities;
4+
using Taskdeck.Domain.Exceptions;
5+
6+
namespace Taskdeck.Application.Services;
7+
8+
public class DailySealService : IDailySealService
9+
{
10+
private readonly IUnitOfWork _unitOfWork;
11+
12+
public DailySealService(IUnitOfWork unitOfWork)
13+
{
14+
_unitOfWork = unitOfWork;
15+
}
16+
17+
public async Task<Result<DailySealResponse>> SealDayAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default)
18+
{
19+
if (userId == Guid.Empty)
20+
return Result.Failure<DailySealResponse>(ErrorCodes.ValidationError, "UserId cannot be empty");
21+
22+
var now = DateTimeOffset.UtcNow;
23+
24+
if (date > DateOnly.FromDateTime(now.UtcDateTime))
25+
return Result.Failure<DailySealResponse>(ErrorCodes.ValidationError, "Cannot seal a future date");
26+
27+
var snapshot = await _unitOfWork.DailySnapshots.GetByUserAndDateAsync(userId, date, cancellationToken);
28+
29+
var wasAlreadySealed = false;
30+
31+
if (snapshot is null)
32+
{
33+
snapshot = new DailySnapshot(userId, date, now);
34+
snapshot.Seal(now);
35+
await _unitOfWork.DailySnapshots.AddAsync(snapshot, cancellationToken);
36+
}
37+
else
38+
{
39+
wasAlreadySealed = snapshot.IsSealed;
40+
snapshot.Seal(now);
41+
}
42+
43+
await _unitOfWork.SaveChangesAsync(cancellationToken);
44+
45+
// Re-fetch to ensure accurate response after potential concurrent seal resolution.
46+
// If TryResolveDuplicateDailySnapshotConflicts detached our entity, the persisted
47+
// snapshot will have a different Id — meaning another request won the race.
48+
var persisted = await _unitOfWork.DailySnapshots.GetByUserAndDateAsync(userId, date, cancellationToken);
49+
if (persisted != null && persisted.Id != snapshot.Id)
50+
{
51+
return Result.Success(new DailySealResponse(persisted.SealedAt!.Value, WasAlreadySealed: true));
52+
}
53+
54+
return Result.Success(new DailySealResponse(snapshot.SealedAt!.Value, wasAlreadySealed));
55+
}
56+
57+
public async Task<Result<DailySealStatusResponse>> GetSealStatusAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default)
58+
{
59+
if (userId == Guid.Empty)
60+
return Result.Failure<DailySealStatusResponse>(ErrorCodes.ValidationError, "UserId cannot be empty");
61+
62+
var snapshot = await _unitOfWork.DailySnapshots.GetByUserAndDateAsync(userId, date, cancellationToken);
63+
64+
if (snapshot is null)
65+
return Result.Success(new DailySealStatusResponse(date, false, null));
66+
67+
return Result.Success(new DailySealStatusResponse(date, snapshot.IsSealed, snapshot.SealedAt));
68+
}
69+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using Taskdeck.Domain.Common;
2+
3+
namespace Taskdeck.Application.Services;
4+
5+
public interface IDailySealService
6+
{
7+
Task<Result<DailySealResponse>> SealDayAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default);
8+
Task<Result<DailySealStatusResponse>> GetSealStatusAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default);
9+
}
10+
11+
public sealed record DailySealResponse(DateTimeOffset SealedAt, bool WasAlreadySealed);
12+
public sealed record DailySealStatusResponse(DateOnly Date, bool IsSealed, DateTimeOffset? SealedAt);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using Taskdeck.Domain.Common;
2+
using Taskdeck.Domain.Exceptions;
3+
4+
namespace Taskdeck.Domain.Entities;
5+
6+
public class DailySnapshot : Entity
7+
{
8+
public Guid UserId { get; private set; }
9+
public DateOnly Date { get; private set; }
10+
public DateTimeOffset? SealedAt { get; private set; }
11+
12+
public bool IsSealed => SealedAt.HasValue;
13+
14+
private DailySnapshot() { } // EF Core
15+
16+
public DailySnapshot(Guid userId, DateOnly date, DateTimeOffset now)
17+
{
18+
if (userId == Guid.Empty)
19+
throw new DomainException(ErrorCodes.ValidationError, "UserId cannot be empty");
20+
21+
if (date > DateOnly.FromDateTime(now.UtcDateTime))
22+
throw new DomainException(ErrorCodes.ValidationError, "Date must not be in the future");
23+
24+
UserId = userId;
25+
Date = date;
26+
}
27+
28+
/// <summary>
29+
/// Seals the day's snapshot. Idempotent: if already sealed, this is a no-op.
30+
/// </summary>
31+
public void Seal(DateTimeOffset now)
32+
{
33+
if (IsSealed)
34+
return;
35+
36+
SealedAt = now;
37+
Touch();
38+
}
39+
}

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<IDailySnapshotRepository, DailySnapshotRepository>();
7879
services.AddScoped<ITomorrowNoteRepository, TomorrowNoteRepository>();
7980

8081
// Vector index is local; hash-based in-memory embeddings are development/test

0 commit comments

Comments
 (0)