Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion backend/src/Taskdeck.Api/Controllers/TodayController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
namespace Taskdeck.Api.Controllers;

/// <summary>
/// Today-view endpoints: streak data, cadence aggregation, and daily dossier data.
/// Today-view endpoints: streak data, cadence aggregation, daily dossier data, tomorrow notes, and day-seal operations.
/// </summary>
[ApiController]
[Authorize]
Expand All @@ -19,17 +19,20 @@ public class TodayController : AuthenticatedControllerBase
{
private readonly IStreakService _streakService;
private readonly ICadenceService _cadenceService;
private readonly IDailySealService _dailySealService;
private readonly ITomorrowNoteService _tomorrowNoteService;

public TodayController(
IStreakService streakService,
ICadenceService cadenceService,
IDailySealService dailySealService,
ITomorrowNoteService tomorrowNoteService,
IUserContext userContext)
: base(userContext)
{
_streakService = streakService;
_cadenceService = cadenceService;
_dailySealService = dailySealService;
_tomorrowNoteService = tomorrowNoteService;
}

Expand Down Expand Up @@ -106,6 +109,26 @@ public async Task<IActionResult> GetCadence(
return Ok(response);
}

[HttpPost("seal")]
public async Task<IActionResult> SealDay([FromBody] SealDayRequest request, CancellationToken cancellationToken)
{
if (!TryGetCurrentUserId(out var userId, out var errorResult))
return errorResult!;

var result = await _dailySealService.SealDayAsync(userId, request.Date, cancellationToken);
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}

[HttpGet("seal")]
public async Task<IActionResult> GetSealStatus([FromQuery] DateOnly date, CancellationToken cancellationToken)
{
if (!TryGetCurrentUserId(out var userId, out var errorResult))
return errorResult!;

var result = await _dailySealService.GetSealStatusAsync(userId, date, cancellationToken);
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}

/// <summary>
/// Gets the tomorrow note for the given date.
/// The note was written the previous day and is displayed on the specified date's morning open.
Expand Down Expand Up @@ -153,3 +176,5 @@ public async Task<IActionResult> SaveTomorrowNote(
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}
}

public sealed record SealDayRequest(DateOnly Date);
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped<IToolExecutor, ProposeBulkMoveExecutor>();
services.AddScoped<IToolExecutor, ProposeCreateColumnExecutor>();

services.AddScoped<IDailySealService, DailySealService>();

services.AddScoped<ToolExecutorRegistry>(sp =>
new ToolExecutorRegistry(sp.GetServices<IToolExecutor>()));
services.AddScoped<IToolStatusNotifier, SignalRToolStatusNotifier>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Taskdeck.Domain.Entities;

namespace Taskdeck.Application.Interfaces;

public interface IDailySnapshotRepository : IRepository<DailySnapshot>
{
Task<DailySnapshot?> GetByUserAndDateAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DailySnapshot>> GetSealedDaysAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default);
}
1 change: 1 addition & 0 deletions backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public interface IUnitOfWork
IConnectorEventRepository ConnectorEvents { get; }
IConnectorCredentialRepository ConnectorCredentials { get; }
IProposalRevisionRepository ProposalRevisions { get; }
IDailySnapshotRepository DailySnapshots { get; }
ITomorrowNoteRepository TomorrowNotes { get; }

Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
Expand Down
69 changes: 69 additions & 0 deletions backend/src/Taskdeck.Application/Services/DailySealService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using Taskdeck.Application.Interfaces;
using Taskdeck.Domain.Common;
using Taskdeck.Domain.Entities;
using Taskdeck.Domain.Exceptions;

namespace Taskdeck.Application.Services;

public class DailySealService : IDailySealService
{
private readonly IUnitOfWork _unitOfWork;

public DailySealService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}

public async Task<Result<DailySealResponse>> SealDayAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default)
{
if (userId == Guid.Empty)
return Result.Failure<DailySealResponse>(ErrorCodes.ValidationError, "UserId cannot be empty");

var now = DateTimeOffset.UtcNow;

if (date > DateOnly.FromDateTime(now.UtcDateTime))
return Result.Failure<DailySealResponse>(ErrorCodes.ValidationError, "Cannot seal a future date");

var snapshot = await _unitOfWork.DailySnapshots.GetByUserAndDateAsync(userId, date, cancellationToken);

var wasAlreadySealed = false;

if (snapshot is null)
{
snapshot = new DailySnapshot(userId, date, now);
snapshot.Seal(now);
await _unitOfWork.DailySnapshots.AddAsync(snapshot, cancellationToken);
}
else
{
wasAlreadySealed = snapshot.IsSealed;
snapshot.Seal(now);
}

await _unitOfWork.SaveChangesAsync(cancellationToken);

// Re-fetch to ensure accurate response after potential concurrent seal resolution.
// If TryResolveDuplicateDailySnapshotConflicts detached our entity, the persisted
// snapshot will have a different Id — meaning another request won the race.
var persisted = await _unitOfWork.DailySnapshots.GetByUserAndDateAsync(userId, date, cancellationToken);
if (persisted != null && persisted.Id != snapshot.Id)
{
return Result.Success(new DailySealResponse(persisted.SealedAt!.Value, WasAlreadySealed: true));
}

return Result.Success(new DailySealResponse(snapshot.SealedAt!.Value, wasAlreadySealed));
}

public async Task<Result<DailySealStatusResponse>> GetSealStatusAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default)
{
if (userId == Guid.Empty)
return Result.Failure<DailySealStatusResponse>(ErrorCodes.ValidationError, "UserId cannot be empty");

var snapshot = await _unitOfWork.DailySnapshots.GetByUserAndDateAsync(userId, date, cancellationToken);

if (snapshot is null)
return Result.Success(new DailySealStatusResponse(date, false, null));

return Result.Success(new DailySealStatusResponse(date, snapshot.IsSealed, snapshot.SealedAt));
}
}
12 changes: 12 additions & 0 deletions backend/src/Taskdeck.Application/Services/IDailySealService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Taskdeck.Domain.Common;

namespace Taskdeck.Application.Services;

public interface IDailySealService
{
Task<Result<DailySealResponse>> SealDayAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default);
Task<Result<DailySealStatusResponse>> GetSealStatusAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default);
}

public sealed record DailySealResponse(DateTimeOffset SealedAt, bool WasAlreadySealed);
public sealed record DailySealStatusResponse(DateOnly Date, bool IsSealed, DateTimeOffset? SealedAt);
39 changes: 39 additions & 0 deletions backend/src/Taskdeck.Domain/Entities/DailySnapshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Taskdeck.Domain.Common;
using Taskdeck.Domain.Exceptions;

namespace Taskdeck.Domain.Entities;

public class DailySnapshot : Entity
{
public Guid UserId { get; private set; }
public DateOnly Date { get; private set; }
public DateTimeOffset? SealedAt { get; private set; }

public bool IsSealed => SealedAt.HasValue;

private DailySnapshot() { } // EF Core

public DailySnapshot(Guid userId, DateOnly date, DateTimeOffset now)
{
if (userId == Guid.Empty)
throw new DomainException(ErrorCodes.ValidationError, "UserId cannot be empty");

if (date > DateOnly.FromDateTime(now.UtcDateTime))
throw new DomainException(ErrorCodes.ValidationError, "Date must not be in the future");

UserId = userId;
Date = date;
}

/// <summary>
/// Seals the day's snapshot. Idempotent: if already sealed, this is a no-op.
/// </summary>
public void Seal(DateTimeOffset now)
{
if (IsSealed)
return;

SealedAt = now;
Touch();
}
}
1 change: 1 addition & 0 deletions backend/src/Taskdeck.Infrastructure/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
services.AddScoped<IFtsKnowledgeSearchService>(sp =>
sp.GetRequiredService<Taskdeck.Infrastructure.Services.KnowledgeFtsSearchService>());
services.AddScoped<IProposalRevisionRepository, ProposalRevisionRepository>();
services.AddScoped<IDailySnapshotRepository, DailySnapshotRepository>();
services.AddScoped<ITomorrowNoteRepository, TomorrowNoteRepository>();

// Vector index is local; hash-based in-memory embeddings are development/test
Expand Down
Loading
Loading