diff --git a/backend/src/Taskdeck.Api/Controllers/TodayController.cs b/backend/src/Taskdeck.Api/Controllers/TodayController.cs index 4cde80f83..623682653 100644 --- a/backend/src/Taskdeck.Api/Controllers/TodayController.cs +++ b/backend/src/Taskdeck.Api/Controllers/TodayController.cs @@ -9,7 +9,7 @@ namespace Taskdeck.Api.Controllers; /// -/// 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. /// [ApiController] [Authorize] @@ -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; } @@ -106,6 +109,26 @@ public async Task GetCadence( return Ok(response); } + [HttpPost("seal")] + public async Task 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 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(); + } + /// /// 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. @@ -153,3 +176,5 @@ public async Task SaveTomorrowNote( return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } } + +public sealed record SealDayRequest(DateOnly Date); diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index 805df4461..c62361aa7 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -124,6 +124,8 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => new ToolExecutorRegistry(sp.GetServices())); services.AddScoped(); diff --git a/backend/src/Taskdeck.Application/Interfaces/IDailySnapshotRepository.cs b/backend/src/Taskdeck.Application/Interfaces/IDailySnapshotRepository.cs new file mode 100644 index 000000000..825458242 --- /dev/null +++ b/backend/src/Taskdeck.Application/Interfaces/IDailySnapshotRepository.cs @@ -0,0 +1,9 @@ +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Application.Interfaces; + +public interface IDailySnapshotRepository : IRepository +{ + Task GetByUserAndDateAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default); + Task> GetSealedDaysAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs index 1f1552840..433c86768 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs @@ -34,6 +34,7 @@ public interface IUnitOfWork IConnectorEventRepository ConnectorEvents { get; } IConnectorCredentialRepository ConnectorCredentials { get; } IProposalRevisionRepository ProposalRevisions { get; } + IDailySnapshotRepository DailySnapshots { get; } ITomorrowNoteRepository TomorrowNotes { get; } Task SaveChangesAsync(CancellationToken cancellationToken = default); diff --git a/backend/src/Taskdeck.Application/Services/DailySealService.cs b/backend/src/Taskdeck.Application/Services/DailySealService.cs new file mode 100644 index 000000000..30e922334 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/DailySealService.cs @@ -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> SealDayAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty"); + + var now = DateTimeOffset.UtcNow; + + if (date > DateOnly.FromDateTime(now.UtcDateTime)) + return Result.Failure(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> GetSealStatusAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(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)); + } +} diff --git a/backend/src/Taskdeck.Application/Services/IDailySealService.cs b/backend/src/Taskdeck.Application/Services/IDailySealService.cs new file mode 100644 index 000000000..3321b94f3 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IDailySealService.cs @@ -0,0 +1,12 @@ +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +public interface IDailySealService +{ + Task> SealDayAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default); + Task> 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); diff --git a/backend/src/Taskdeck.Domain/Entities/DailySnapshot.cs b/backend/src/Taskdeck.Domain/Entities/DailySnapshot.cs new file mode 100644 index 000000000..d050985f6 --- /dev/null +++ b/backend/src/Taskdeck.Domain/Entities/DailySnapshot.cs @@ -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; + } + + /// + /// Seals the day's snapshot. Idempotent: if already sealed, this is a no-op. + /// + public void Seal(DateTimeOffset now) + { + if (IsSealed) + return; + + SealedAt = now; + Touch(); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs index a6e92d3f0..a0ca0e0de 100644 --- a/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs +++ b/backend/src/Taskdeck.Infrastructure/DependencyInjection.cs @@ -75,6 +75,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); + services.AddScoped(); services.AddScoped(); // Vector index is local; hash-based in-memory embeddings are development/test diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/20260425203404_AddDailySnapshots.Designer.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260425203404_AddDailySnapshots.Designer.cs new file mode 100644 index 000000000..6167c3d83 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260425203404_AddDailySnapshots.Designer.cs @@ -0,0 +1,2309 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Taskdeck.Infrastructure.Persistence; + +#nullable disable + +namespace Taskdeck.Infrastructure.Migrations +{ + [DbContext(typeof(TaskdeckDbContext))] + [Migration("20260425203404_AddDailySnapshots")] + partial class AddDailySnapshots + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.26"); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AgentProfile", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PolicyJson") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("TEXT"); + + b.Property("ScopeBoardId") + .HasColumnType("TEXT"); + + b.Property("ScopeType") + .HasColumnType("INTEGER"); + + b.Property("TemplateKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TemplateKey"); + + b.HasIndex("UserId"); + + b.ToTable("AgentProfiles", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AgentRun", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentProfileId") + .HasColumnType("TEXT"); + + b.Property("ApproxCostUsd") + .HasColumnType("decimal(10, 6)"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FailureReason") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Objective") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StepsExecuted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("TokensUsed") + .HasColumnType("INTEGER"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentProfileId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("AgentRuns", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AgentRunEvent", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Payload") + .IsRequired() + .HasMaxLength(16000) + .HasColumnType("TEXT"); + + b.Property("RunId") + .HasColumnType("TEXT"); + + b.Property("SequenceNumber") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "SequenceNumber") + .IsUnique(); + + b.ToTable("AgentRunEvents", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("KeyPrefix_") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT") + .HasColumnName("KeyPrefix"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ArchiveItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ArchivedAt") + .HasColumnType("TEXT"); + + b.Property("ArchivedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RestoreStatus") + .HasColumnType("INTEGER"); + + b.Property("RestoredAt") + .HasColumnType("TEXT"); + + b.Property("RestoredByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArchivedAt"); + + b.HasIndex("ArchivedByUserId"); + + b.HasIndex("BoardId"); + + b.HasIndex("RestoreStatus"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("ArchiveItems", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .HasColumnType("INTEGER"); + + b.Property("Changes") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityId", "Timestamp") + .HasDatabaseName("IX_AuditLogs_EntityId_Timestamp"); + + b.HasIndex("EntityType", "EntityId"); + + b.HasIndex("UserId", "Timestamp") + .HasDatabaseName("IX_AuditLogs_UserId_Timestamp"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AppliedAt") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DiffPreview") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("FailureReason") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .HasColumnType("INTEGER"); + + b.Property("SourceReferenceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ValidationIssues") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("Status"); + + b.ToTable("AutomationProposals", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActionType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpectedVersion") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("TargetId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IdempotencyKey") + .IsUnique(); + + b.HasIndex("ProposalId"); + + b.HasIndex("ProposalId", "Sequence"); + + b.ToTable("AutomationProposalOperations", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("IsArchived") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Boards", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.BoardAccess", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("TEXT"); + + b.Property("GrantedBy") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("BoardId", "UserId") + .IsUnique(); + + b.ToTable("BoardAccesses", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Card", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BlockReason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("ColumnId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("DueDate") + .HasColumnType("TEXT"); + + b.Property("IsBlocked") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ColumnId"); + + b.HasIndex("BoardId", "ColumnId") + .HasDatabaseName("IX_Cards_BoardId_ColumnId"); + + b.ToTable("Cards", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardComment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorUserId") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CardId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EditedAt") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("ParentCommentId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorUserId"); + + b.HasIndex("BoardId"); + + b.HasIndex("CardId"); + + b.HasIndex("ParentCommentId"); + + b.HasIndex("CardId", "CreatedAt"); + + b.ToTable("CardComments", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardCommentMention", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CardCommentId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("MentionedUserId") + .HasColumnType("TEXT"); + + b.Property("MentionedUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CardCommentId"); + + b.HasIndex("MentionedUserId"); + + b.HasIndex("CardCommentId", "MentionedUserId") + .IsUnique(); + + b.ToTable("CardCommentMentions", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardLabel", b => + { + b.Property("CardId") + .HasColumnType("TEXT"); + + b.Property("LabelId") + .HasColumnType("TEXT"); + + b.HasKey("CardId", "LabelId"); + + b.HasIndex("LabelId"); + + b.ToTable("CardLabels", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatMessage", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DegradedReason") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("MessageType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("TokenUsage") + .HasColumnType("INTEGER"); + + b.Property("ToolCallMetadataJson") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ProposalId"); + + b.HasIndex("SessionId"); + + b.ToTable("ChatMessages", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("ChatSessions", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WipLimit") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("BoardId", "Position") + .IsUnique(); + + b.ToTable("Columns", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRun", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("OutputPreview") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TemplateName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Truncated") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("Status"); + + b.ToTable("CommandRuns", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRunLog", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CommandRunId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CommandRunId"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp"); + + b.ToTable("CommandRunLogs", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ConnectorCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthMethod") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ConnectorId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedValue") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("KeyVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RotatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("ConnectorId", "UserId") + .IsUnique(); + + b.ToTable("ConnectorCredentials", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ConnectorEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConnectorId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConnectorId"); + + b.HasIndex("ConnectorId", "CreatedAt"); + + b.ToTable("ConnectorEvents", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.DailySnapshot", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("SealedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Date") + .IsUnique(); + + b.ToTable("DailySnapshots", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ExternalLogin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ProviderUserId") + .IsUnique(); + + b.ToTable("ExternalLogins", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.IntegrationConnector", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ConnectorType") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("IntegrationConnectors", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.KnowledgeChunk", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChunkIndex") + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DocumentId") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.HasIndex("DocumentId", "ChunkIndex") + .IsUnique(); + + b.ToTable("KnowledgeChunks", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.KnowledgeDocument", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(50000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsArchived") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "IsArchived"); + + b.ToTable("KnowledgeDocuments", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("ColorHex") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.ToTable("Labels", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.LlmRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProcessedAt") + .HasColumnType("TEXT"); + + b.Property("RequestType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("LlmRequests", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.LlmUsageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Surface") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("Surface", "CreatedAt"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("LlmUsageRecords", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.MfaCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RecoveryCodes") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Secret") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("MfaCredentials", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("Cadence") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeduplicationKey") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsRead") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ReadAt") + .HasColumnType("TEXT"); + + b.Property("SourceEntityId") + .HasColumnType("TEXT"); + + b.Property("SourceEntityType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BoardId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DeduplicationKey") + .IsUnique(); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.NotificationPreference", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignmentDigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("AssignmentImmediateEnabled") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("InAppChannelEnabled") + .HasColumnType("INTEGER"); + + b.Property("MentionDigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("MentionImmediateEnabled") + .HasColumnType("INTEGER"); + + b.Property("ProposalOutcomeDigestEnabled") + .HasColumnType("INTEGER"); + + b.Property("ProposalOutcomeImmediateEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NotificationPreferences", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.OAuthAuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsConsumed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("ProviderData") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("TEXT") + .HasDefaultValue("login"); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("ExpiresAt"); + + b.ToTable("OAuthAuthCodes", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.OutboundWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeliveredAt") + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("LastAttemptAt") + .HasColumnType("TEXT"); + + b.Property("LastErrorMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastResponseStatusCode") + .HasColumnType("INTEGER"); + + b.Property("NextAttemptAt") + .HasColumnType("TEXT"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("SubscriptionId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SubscriptionId"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("OutboundWebhookDeliveries", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.OutboundWebhookSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BoardId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EventFilters") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastTriggeredAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("RevokedByUserId") + .HasColumnType("TEXT"); + + b.Property("SigningSecret") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("BoardId", "IsActive"); + + b.ToTable("OutboundWebhookSubscriptions", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ProposalOutcome", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AverageFieldConfidence") + .HasColumnType("REAL"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedAt") + .HasColumnType("TEXT"); + + b.Property("DecidedByUserId") + .HasColumnType("TEXT"); + + b.Property("Decision") + .HasColumnType("INTEGER"); + + b.Property("DecisionLatencySeconds") + .HasColumnType("REAL"); + + b.Property("EditedFieldCount") + .HasColumnType("INTEGER"); + + b.Property("FieldCount") + .HasColumnType("INTEGER"); + + b.Property("ModelId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OutcomeType") + .HasColumnType("INTEGER"); + + b.Property("ProposalId") + .HasColumnType("TEXT"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DecidedByUserId"); + + b.HasIndex("Decision"); + + b.HasIndex("ProposalId"); + + b.ToTable("ProposalOutcomes", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ProposalRevision", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EditorUserId") + .HasColumnType("TEXT"); + + b.Property("ProposalId") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("RevisedAt") + .HasColumnType("TEXT"); + + b.Property("RevisedPayload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionNumber") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EditorUserId"); + + b.HasIndex("ProposalId"); + + b.HasIndex("ProposalId", "RevisionNumber") + .IsUnique(); + + b.ToTable("ProposalRevisions", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultRole") + .HasColumnType("INTEGER"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("MfaEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("TokenInvalidatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.UserPreference", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("OnboardingCompletedAt") + .HasColumnType("TEXT"); + + b.Property("OnboardingDismissedAt") + .HasColumnType("TEXT"); + + b.Property("OnboardingVisibility") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("WorkspaceMode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("UserPreferences", (string)null); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AgentRun", b => + { + b.HasOne("Taskdeck.Domain.Entities.AgentProfile", null) + .WithMany() + .HasForeignKey("AgentProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AgentRunEvent", b => + { + b.HasOne("Taskdeck.Domain.Entities.AgentRun", "Run") + .WithMany("Events") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ApiKey", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AuditLog", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposalOperation", b => + { + b.HasOne("Taskdeck.Domain.Entities.AutomationProposal", "Proposal") + .WithMany("Operations") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.BoardAccess", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("BoardAccesses") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Card", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("Cards") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.Column", "Column") + .WithMany("Cards") + .HasForeignKey("ColumnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("Column"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardComment", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", "AuthorUser") + .WithMany() + .HasForeignKey("AuthorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.Card", "Card") + .WithMany() + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.CardComment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AuthorUser"); + + b.Navigation("Card"); + + b.Navigation("ParentComment"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardCommentMention", b => + { + b.HasOne("Taskdeck.Domain.Entities.CardComment", "CardComment") + .WithMany("Mentions") + .HasForeignKey("CardCommentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.User", "MentionedUser") + .WithMany() + .HasForeignKey("MentionedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CardComment"); + + b.Navigation("MentionedUser"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardLabel", b => + { + b.HasOne("Taskdeck.Domain.Entities.Card", "Card") + .WithMany("CardLabels") + .HasForeignKey("CardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.Label", "Label") + .WithMany("CardLabels") + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Card"); + + b.Navigation("Label"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatMessage", b => + { + b.HasOne("Taskdeck.Domain.Entities.ChatSession", "Session") + .WithMany("Messages") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("Columns") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRunLog", b => + { + b.HasOne("Taskdeck.Domain.Entities.CommandRun", "CommandRun") + .WithMany("Logs") + .HasForeignKey("CommandRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CommandRun"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ConnectorCredential", b => + { + b.HasOne("Taskdeck.Domain.Entities.IntegrationConnector", null) + .WithMany() + .HasForeignKey("ConnectorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ConnectorEvent", b => + { + b.HasOne("Taskdeck.Domain.Entities.IntegrationConnector", null) + .WithMany() + .HasForeignKey("ConnectorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ExternalLogin", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.IntegrationConnector", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.KnowledgeChunk", b => + { + b.HasOne("Taskdeck.Domain.Entities.KnowledgeDocument", null) + .WithMany() + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany("Labels") + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.LlmRequest", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany() + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.MfaCredential", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Notification", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.NotificationPreference", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.OutboundWebhookDelivery", b => + { + b.HasOne("Taskdeck.Domain.Entities.OutboundWebhookSubscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.OutboundWebhookSubscription", b => + { + b.HasOne("Taskdeck.Domain.Entities.Board", "Board") + .WithMany() + .HasForeignKey("BoardId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Taskdeck.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Board"); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ProposalOutcome", b => + { + b.HasOne("Taskdeck.Domain.Entities.AutomationProposal", "Proposal") + .WithMany("Outcomes") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ProposalRevision", b => + { + b.HasOne("Taskdeck.Domain.Entities.AutomationProposal", "Proposal") + .WithMany("Revisions") + .HasForeignKey("ProposalId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Proposal"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.UserPreference", b => + { + b.HasOne("Taskdeck.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AgentRun", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.AutomationProposal", b => + { + b.Navigation("Operations"); + + b.Navigation("Outcomes"); + + b.Navigation("Revisions"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Board", b => + { + b.Navigation("BoardAccesses"); + + b.Navigation("Cards"); + + b.Navigation("Columns"); + + b.Navigation("Labels"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Card", b => + { + b.Navigation("CardLabels"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CardComment", b => + { + b.Navigation("Mentions"); + + b.Navigation("Replies"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.ChatSession", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Column", b => + { + b.Navigation("Cards"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.CommandRun", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("Taskdeck.Domain.Entities.Label", b => + { + b.Navigation("CardLabels"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/20260425203404_AddDailySnapshots.cs b/backend/src/Taskdeck.Infrastructure/Migrations/20260425203404_AddDailySnapshots.cs new file mode 100644 index 000000000..5cde1f1c2 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Migrations/20260425203404_AddDailySnapshots.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Taskdeck.Infrastructure.Migrations +{ + /// + public partial class AddDailySnapshots : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DailySnapshots", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Date = table.Column(type: "TEXT", nullable: false), + SealedAt = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DailySnapshots", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_DailySnapshots_UserId", + table: "DailySnapshots", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_DailySnapshots_UserId_Date", + table: "DailySnapshots", + columns: new[] { "UserId", "Date" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DailySnapshots"); + } + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs b/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs index e5167bfab..db11db4ec 100644 --- a/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs +++ b/backend/src/Taskdeck.Infrastructure/Migrations/TaskdeckDbContextModelSnapshot.cs @@ -1023,6 +1023,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ConnectorEvents", (string)null); }); + modelBuilder.Entity("Taskdeck.Domain.Entities.DailySnapshot", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("SealedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Date") + .IsUnique(); + + b.ToTable("DailySnapshots", (string)null); + }); + modelBuilder.Entity("Taskdeck.Domain.Entities.ExternalLogin", b => { b.Property("Id") diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/DailySnapshotConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/DailySnapshotConfiguration.cs new file mode 100644 index 000000000..fd97e4f93 --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/DailySnapshotConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Taskdeck.Domain.Entities; + +namespace Taskdeck.Infrastructure.Persistence.Configurations; + +public class DailySnapshotConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("DailySnapshots"); + + builder.HasKey(ds => ds.Id); + + builder.Property(ds => ds.Id) + .ValueGeneratedNever(); + + builder.Property(ds => ds.UserId) + .IsRequired(); + + builder.Property(ds => ds.Date) + .IsRequired(); + + builder.Property(ds => ds.SealedAt); + + builder.Property(ds => ds.CreatedAt) + .IsRequired(); + + builder.Property(ds => ds.UpdatedAt) + .IsRequired() + .IsConcurrencyToken(); + + // Unique constraint: one snapshot per user per day + builder.HasIndex(ds => new { ds.UserId, ds.Date }) + .IsUnique(); + + builder.HasIndex(ds => ds.UserId); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs b/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs index 1bd80962e..cbabd2aab 100644 --- a/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs +++ b/backend/src/Taskdeck.Infrastructure/Persistence/TaskdeckDbContext.cs @@ -47,6 +47,7 @@ public TaskdeckDbContext(DbContextOptions options) : base(opt public DbSet ConnectorCredentials => Set(); public DbSet ProposalRevisions => Set(); public DbSet ProposalOutcomes => Set(); + public DbSet DailySnapshots => Set(); public DbSet TomorrowNotes => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/DailySnapshotRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/DailySnapshotRepository.cs new file mode 100644 index 000000000..4fb2550db --- /dev/null +++ b/backend/src/Taskdeck.Infrastructure/Repositories/DailySnapshotRepository.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Entities; +using Taskdeck.Infrastructure.Persistence; + +namespace Taskdeck.Infrastructure.Repositories; + +public class DailySnapshotRepository : Repository, IDailySnapshotRepository +{ + public DailySnapshotRepository(TaskdeckDbContext context) : base(context) + { + } + + public async Task GetByUserAndDateAsync(Guid userId, DateOnly date, CancellationToken cancellationToken = default) + { + return await _dbSet + .FirstOrDefaultAsync(ds => ds.UserId == userId && ds.Date == date, cancellationToken); + } + + public async Task> GetSealedDaysAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default) + { + return await _dbSet + .Where(ds => ds.UserId == userId && ds.SealedAt != null && ds.Date >= from && ds.Date <= to) + .OrderBy(ds => ds.Date) + .ToListAsync(cancellationToken); + } +} diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs index fce3a869b..26f40a6cf 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs @@ -1,3 +1,4 @@ +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Taskdeck.Application.Interfaces; @@ -9,6 +10,8 @@ namespace Taskdeck.Infrastructure.Repositories; public class UnitOfWork : IUnitOfWork { + private const int MaxSqliteWriteLockRetries = 5; + private readonly TaskdeckDbContext _context; private IDbContextTransaction? _transaction; @@ -46,6 +49,7 @@ public UnitOfWork( IConnectorEventRepository connectorEvents, IConnectorCredentialRepository connectorCredentials, IProposalRevisionRepository proposalRevisions, + IDailySnapshotRepository dailySnapshots, ITomorrowNoteRepository tomorrowNotes) { _context = context; @@ -81,6 +85,7 @@ public UnitOfWork( ConnectorEvents = connectorEvents; ConnectorCredentials = connectorCredentials; ProposalRevisions = proposalRevisions; + DailySnapshots = dailySnapshots; TomorrowNotes = tomorrowNotes; } @@ -116,31 +121,41 @@ public UnitOfWork( public IConnectorEventRepository ConnectorEvents { get; } public IConnectorCredentialRepository ConnectorCredentials { get; } public IProposalRevisionRepository ProposalRevisions { get; } + public IDailySnapshotRepository DailySnapshots { get; } public ITomorrowNoteRepository TomorrowNotes { get; } public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { - try - { - return await _context.SaveChangesAsync(cancellationToken); - } - catch (DbUpdateConcurrencyException ex) - { - throw new DomainException( - ErrorCodes.Conflict, - "Record was updated by another session. Refresh and retry your action.", - ex); - } - catch (DbUpdateException ex) when (IsProposalRevisionUniqueViolation(ex)) - { - throw new DomainException( - ErrorCodes.Conflict, - "Proposal revision was created by another session. Refresh and retry your edit.", - ex); - } - catch (DbUpdateException ex) when (TryResolveRecoverableUniqueConflicts(ex)) + var resolvedRecoverableUniqueConflict = false; + + for (var attempt = 0; ; attempt++) { - return await _context.SaveChangesAsync(cancellationToken); + try + { + return await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException ex) + { + throw new DomainException( + ErrorCodes.Conflict, + "Record was updated by another session. Refresh and retry your action.", + ex); + } + catch (DbUpdateException ex) when (IsProposalRevisionUniqueViolation(ex)) + { + throw new DomainException( + ErrorCodes.Conflict, + "Proposal revision was created by another session. Refresh and retry your edit.", + ex); + } + catch (DbUpdateException ex) when (!resolvedRecoverableUniqueConflict && TryResolveRecoverableUniqueConflicts(ex)) + { + resolvedRecoverableUniqueConflict = true; + } + catch (DbUpdateException ex) when (IsTransientSqliteWriteLock(ex) && attempt < MaxSqliteWriteLockRetries) + { + await Task.Delay(GetSqliteWriteLockRetryDelay(attempt), cancellationToken); + } } } @@ -173,9 +188,13 @@ private bool TryResolveRecoverableUniqueConflicts(DbUpdateException exception) { var resolvedNotificationConflict = TryResolveDuplicateNotificationDeduplicationConflicts(exception); var resolvedUserPreferenceConflict = TryResolveDuplicateUserPreferenceConflicts(exception); + var resolvedDailySnapshotConflict = TryResolveDuplicateDailySnapshotConflicts(exception); var resolvedTomorrowNoteConflict = TryResolveDuplicateTomorrowNoteConflicts(exception); - return resolvedNotificationConflict || resolvedUserPreferenceConflict || resolvedTomorrowNoteConflict; + return resolvedNotificationConflict + || resolvedUserPreferenceConflict + || resolvedDailySnapshotConflict + || resolvedTomorrowNoteConflict; } private bool TryResolveDuplicateNotificationDeduplicationConflicts(DbUpdateException exception) @@ -315,4 +334,72 @@ private static bool IsProposalRevisionUniqueViolation(DbUpdateException exceptio "IX_ProposalRevisions_ProposalId_RevisionNumber", StringComparison.OrdinalIgnoreCase); } + + private bool TryResolveDuplicateDailySnapshotConflicts(DbUpdateException exception) + { + if (!IsDailySnapshotUniqueViolation(exception)) + return false; + + var duplicateSnapshotFound = false; + var pendingSnapshots = _context.ChangeTracker + .Entries() + .Where(entry => entry.State == EntityState.Added) + .ToList(); + + foreach (var pendingSnapshot in pendingSnapshots) + { + var duplicateExists = _context.DailySnapshots + .AsNoTracking() + .Any(ds => + ds.UserId == pendingSnapshot.Entity.UserId + && ds.Date == pendingSnapshot.Entity.Date); + + if (!duplicateExists) + continue; + + pendingSnapshot.State = EntityState.Detached; + duplicateSnapshotFound = true; + } + + return duplicateSnapshotFound; + } + + private static bool IsDailySnapshotUniqueViolation(DbUpdateException exception) + { + if (exception.InnerException is null) + return false; + + return exception.InnerException.Message.Contains( + "DailySnapshots.UserId, DailySnapshots.Date", + StringComparison.OrdinalIgnoreCase) + || exception.InnerException.Message.Contains( + "IX_DailySnapshots_UserId_Date", + StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTransientSqliteWriteLock(DbUpdateException exception) + { + for (var current = exception.InnerException; current is not null; current = current.InnerException) + { + if (current is SqliteException sqliteException + && (sqliteException.SqliteErrorCode == 5 || sqliteException.SqliteErrorCode == 6)) + { + return true; + } + + if (current.Message.Contains("database is locked", StringComparison.OrdinalIgnoreCase) + || current.Message.Contains("database table is locked", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static TimeSpan GetSqliteWriteLockRetryDelay(int attempt) + { + var multiplier = attempt + 1; + return TimeSpan.FromMilliseconds(25 * multiplier * multiplier); + } } diff --git a/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs b/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs index c47bd5a83..0cf2dc681 100644 --- a/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ActiveUserValidationMiddlewareTests.cs @@ -307,6 +307,7 @@ public StubUnitOfWork(User? userToReturn) public IConnectorEventRepository ConnectorEvents => throw new NotImplementedException(); public IConnectorCredentialRepository ConnectorCredentials => throw new NotImplementedException(); public IProposalRevisionRepository ProposalRevisions => throw new NotImplementedException(); + public IDailySnapshotRepository DailySnapshots => throw new NotImplementedException(); public ITomorrowNoteRepository TomorrowNotes => throw new NotImplementedException(); public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); diff --git a/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs b/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs index 7b86abfd0..63989325d 100644 --- a/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/LlmQueueToProposalWorkerTests.cs @@ -850,6 +850,7 @@ public FakeUnitOfWork(ILlmQueueRepository llmQueue) public IConnectorEventRepository ConnectorEvents => null!; public IConnectorCredentialRepository ConnectorCredentials => null!; public IProposalRevisionRepository ProposalRevisions => null!; + public IDailySnapshotRepository DailySnapshots => null!; public ITomorrowNoteRepository TomorrowNotes => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs index a5ca64792..49733449e 100644 --- a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerReliabilityTests.cs @@ -649,6 +649,7 @@ public FakeUnitOfWork(IOutboundWebhookDeliveryRepository deliveries) public IConnectorEventRepository ConnectorEvents => null!; public IConnectorCredentialRepository ConnectorCredentials => null!; public IProposalRevisionRepository ProposalRevisions => null!; + public IDailySnapshotRepository DailySnapshots => null!; public ITomorrowNoteRepository TomorrowNotes => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs index 2f80a3e35..297e9dfb6 100644 --- a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookDeliveryWorkerTests.cs @@ -555,6 +555,7 @@ public FakeUnitOfWork(IOutboundWebhookDeliveryRepository outboundWebhookDelivery public IConnectorEventRepository ConnectorEvents => null!; public IConnectorCredentialRepository ConnectorCredentials => null!; public IProposalRevisionRepository ProposalRevisions => null!; + public IDailySnapshotRepository DailySnapshots => null!; public ITomorrowNoteRepository TomorrowNotes => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs index 9e7921ff2..925d117c7 100644 --- a/backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/OutboundWebhookHmacDeliveryTests.cs @@ -542,6 +542,7 @@ public StubUnitOfWork(IOutboundWebhookDeliveryRepository deliveries) public IConnectorEventRepository ConnectorEvents => null!; public IConnectorCredentialRepository ConnectorCredentials => null!; public IProposalRevisionRepository ProposalRevisions => null!; + public IDailySnapshotRepository DailySnapshots => null!; public ITomorrowNoteRepository TomorrowNotes => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs index edf6c05fc..6a94fb742 100644 --- a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerEdgeCaseTests.cs @@ -350,6 +350,7 @@ public FakeUnitOfWork(IAutomationProposalRepository repo) public IConnectorEventRepository ConnectorEvents => null!; public IConnectorCredentialRepository ConnectorCredentials => null!; public IProposalRevisionRepository ProposalRevisions => null!; + public IDailySnapshotRepository DailySnapshots => null!; public ITomorrowNoteRepository TomorrowNotes => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs index 6a904f193..982b6a4af 100644 --- a/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ProposalHousekeepingWorkerTests.cs @@ -231,6 +231,7 @@ public FakeUnitOfWork(IAutomationProposalRepository automationProposalRepository public IConnectorEventRepository ConnectorEvents => null!; public IConnectorCredentialRepository ConnectorCredentials => null!; public IProposalRevisionRepository ProposalRevisions => null!; + public IDailySnapshotRepository DailySnapshots => null!; public ITomorrowNoteRepository TomorrowNotes => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs b/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs index fd09f8f1a..919c2e752 100644 --- a/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/WorkerResilienceTests.cs @@ -627,6 +627,7 @@ private sealed class FakeUnitOfWorkWithLlmQueue : IUnitOfWork public IConnectorEventRepository ConnectorEvents => null!; public IConnectorCredentialRepository ConnectorCredentials => null!; public IProposalRevisionRepository ProposalRevisions => null!; + public IDailySnapshotRepository DailySnapshots => null!; public ITomorrowNoteRepository TomorrowNotes => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); public Task BeginTransactionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; @@ -669,6 +670,7 @@ private sealed class FakeUnitOfWorkWithProposals : IUnitOfWork public IConnectorEventRepository ConnectorEvents => null!; public IConnectorCredentialRepository ConnectorCredentials => null!; public IProposalRevisionRepository ProposalRevisions => null!; + public IDailySnapshotRepository DailySnapshots => null!; public ITomorrowNoteRepository TomorrowNotes => null!; public Task SaveChangesAsync(CancellationToken cancellationToken = default) => Task.FromResult(0); public Task BeginTransactionAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/backend/tests/Taskdeck.Application.Tests/Services/DailySealServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/DailySealServiceTests.cs new file mode 100644 index 000000000..00ceef2da --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/DailySealServiceTests.cs @@ -0,0 +1,225 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class DailySealServiceTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _snapshotRepoMock; + private readonly DailySealService _service; + + public DailySealServiceTests() + { + _unitOfWorkMock = new Mock(); + _snapshotRepoMock = new Mock(); + _unitOfWorkMock.Setup(u => u.DailySnapshots).Returns(_snapshotRepoMock.Object); + _service = new DailySealService(_unitOfWorkMock.Object); + } + + #region SealDayAsync + + [Fact] + public async Task SealDayAsync_NewDay_CreatesAndSealsSnapshot() + { + var userId = Guid.NewGuid(); + var date = DateOnly.FromDateTime(DateTime.UtcNow); + + _snapshotRepoMock + .Setup(r => r.GetByUserAndDateAsync(userId, date, It.IsAny())) + .ReturnsAsync((DailySnapshot?)null); + + var result = await _service.SealDayAsync(userId, date); + + result.IsSuccess.Should().BeTrue(); + result.Value.WasAlreadySealed.Should().BeFalse(); + result.Value.SealedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); + + _snapshotRepoMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task SealDayAsync_UnsealedExistingDay_SealsSnapshot() + { + var userId = Guid.NewGuid(); + var date = DateOnly.FromDateTime(DateTime.UtcNow); + var existingSnapshot = new DailySnapshot(userId, date, DateTimeOffset.UtcNow); + + _snapshotRepoMock + .Setup(r => r.GetByUserAndDateAsync(userId, date, It.IsAny())) + .ReturnsAsync(existingSnapshot); + + var result = await _service.SealDayAsync(userId, date); + + result.IsSuccess.Should().BeTrue(); + result.Value.WasAlreadySealed.Should().BeFalse(); + result.Value.SealedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); + + _snapshotRepoMock.Verify(r => r.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task SealDayAsync_AlreadySealedDay_IsIdempotent() + { + var userId = Guid.NewGuid(); + var date = DateOnly.FromDateTime(DateTime.UtcNow); + var existingSnapshot = new DailySnapshot(userId, date, DateTimeOffset.UtcNow); + existingSnapshot.Seal(DateTimeOffset.UtcNow.AddHours(-1)); + var originalSealedAt = existingSnapshot.SealedAt!.Value; + + _snapshotRepoMock + .Setup(r => r.GetByUserAndDateAsync(userId, date, It.IsAny())) + .ReturnsAsync(existingSnapshot); + + var result = await _service.SealDayAsync(userId, date); + + result.IsSuccess.Should().BeTrue(); + result.Value.WasAlreadySealed.Should().BeTrue(); + result.Value.SealedAt.Should().Be(originalSealedAt); + } + + [Fact] + public async Task SealDayAsync_EmptyUserId_ReturnsValidationError() + { + var result = await _service.SealDayAsync(Guid.Empty, DateOnly.FromDateTime(DateTime.UtcNow)); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task SealDayAsync_FutureDate_ReturnsValidationError() + { + var futureDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1)); + + var result = await _service.SealDayAsync(Guid.NewGuid(), futureDate); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("future"); + } + + [Fact] + public async Task SealDayAsync_PastDate_Succeeds() + { + var userId = Guid.NewGuid(); + var pastDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-30)); + + _snapshotRepoMock + .Setup(r => r.GetByUserAndDateAsync(userId, pastDate, It.IsAny())) + .ReturnsAsync((DailySnapshot?)null); + + var result = await _service.SealDayAsync(userId, pastDate); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task SealDayAsync_ConcurrentRace_ReturnsWasAlreadySealed_WhenEntityDetached() + { + // Simulate a concurrent seal race: our snapshot is new, but after SaveChanges + // the conflict resolver detached it. Re-fetch returns the winner's snapshot. + var userId = Guid.NewGuid(); + var date = DateOnly.FromDateTime(DateTime.UtcNow); + var winnerSealedAt = DateTimeOffset.UtcNow.AddSeconds(-2); + var winnerSnapshot = new DailySnapshot(userId, date, winnerSealedAt); + winnerSnapshot.Seal(winnerSealedAt); + + var callCount = 0; + _snapshotRepoMock + .Setup(r => r.GetByUserAndDateAsync(userId, date, It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + // First call (before save): no existing snapshot + if (callCount == 1) + return null; + // Second call (re-fetch after save): return the winner's snapshot + return winnerSnapshot; + }); + + var result = await _service.SealDayAsync(userId, date); + + result.IsSuccess.Should().BeTrue(); + result.Value.WasAlreadySealed.Should().BeTrue(); + result.Value.SealedAt.Should().Be(winnerSealedAt); + } + + #endregion + + #region GetSealStatusAsync + + [Fact] + public async Task GetSealStatusAsync_NoSnapshot_ReturnsNotSealed() + { + var userId = Guid.NewGuid(); + var date = DateOnly.FromDateTime(DateTime.UtcNow); + + _snapshotRepoMock + .Setup(r => r.GetByUserAndDateAsync(userId, date, It.IsAny())) + .ReturnsAsync((DailySnapshot?)null); + + var result = await _service.GetSealStatusAsync(userId, date); + + result.IsSuccess.Should().BeTrue(); + result.Value.IsSealed.Should().BeFalse(); + result.Value.SealedAt.Should().BeNull(); + result.Value.Date.Should().Be(date); + } + + [Fact] + public async Task GetSealStatusAsync_SealedSnapshot_ReturnsSealedStatus() + { + var userId = Guid.NewGuid(); + var date = DateOnly.FromDateTime(DateTime.UtcNow); + var snapshot = new DailySnapshot(userId, date, DateTimeOffset.UtcNow); + var sealTime = DateTimeOffset.UtcNow; + snapshot.Seal(sealTime); + + _snapshotRepoMock + .Setup(r => r.GetByUserAndDateAsync(userId, date, It.IsAny())) + .ReturnsAsync(snapshot); + + var result = await _service.GetSealStatusAsync(userId, date); + + result.IsSuccess.Should().BeTrue(); + result.Value.IsSealed.Should().BeTrue(); + result.Value.SealedAt.Should().Be(sealTime); + } + + [Fact] + public async Task GetSealStatusAsync_UnsealedSnapshot_ReturnsNotSealed() + { + var userId = Guid.NewGuid(); + var date = DateOnly.FromDateTime(DateTime.UtcNow); + var snapshot = new DailySnapshot(userId, date, DateTimeOffset.UtcNow); + + _snapshotRepoMock + .Setup(r => r.GetByUserAndDateAsync(userId, date, It.IsAny())) + .ReturnsAsync(snapshot); + + var result = await _service.GetSealStatusAsync(userId, date); + + result.IsSuccess.Should().BeTrue(); + result.Value.IsSealed.Should().BeFalse(); + result.Value.SealedAt.Should().BeNull(); + } + + [Fact] + public async Task GetSealStatusAsync_EmptyUserId_ReturnsValidationError() + { + var result = await _service.GetSealStatusAsync(Guid.Empty, DateOnly.FromDateTime(DateTime.UtcNow)); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + #endregion +} diff --git a/backend/tests/Taskdeck.Domain.Tests/Entities/DailySnapshotTests.cs b/backend/tests/Taskdeck.Domain.Tests/Entities/DailySnapshotTests.cs new file mode 100644 index 000000000..d69d80da3 --- /dev/null +++ b/backend/tests/Taskdeck.Domain.Tests/Entities/DailySnapshotTests.cs @@ -0,0 +1,135 @@ +using FluentAssertions; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Domain.Tests.Entities; + +public class DailySnapshotTests +{ + private static readonly DateTimeOffset Now = new(2026, 4, 25, 12, 0, 0, TimeSpan.Zero); + private static readonly DateOnly Today = DateOnly.FromDateTime(Now.UtcDateTime); + + #region Construction + + [Fact] + public void Constructor_ValidInputs_CreatesUnsealedSnapshot() + { + var userId = Guid.NewGuid(); + + var snapshot = new DailySnapshot(userId, Today, Now); + + snapshot.UserId.Should().Be(userId); + snapshot.Date.Should().Be(Today); + snapshot.IsSealed.Should().BeFalse(); + snapshot.SealedAt.Should().BeNull(); + snapshot.Id.Should().NotBe(Guid.Empty); + snapshot.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public void Constructor_EmptyUserId_ThrowsDomainException() + { + var act = () => new DailySnapshot(Guid.Empty, Today, Now); + + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError) + .WithMessage("*UserId*"); + } + + [Fact] + public void Constructor_FutureDate_ThrowsDomainException() + { + var futureDate = Today.AddDays(1); + + var act = () => new DailySnapshot(Guid.NewGuid(), futureDate, Now); + + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError) + .WithMessage("*future*"); + } + + [Fact] + public void Constructor_TodayDate_Succeeds() + { + var snapshot = new DailySnapshot(Guid.NewGuid(), Today, Now); + + snapshot.Date.Should().Be(Today); + } + + [Fact] + public void Constructor_PastDate_Succeeds() + { + var pastDate = Today.AddDays(-7); + + var snapshot = new DailySnapshot(Guid.NewGuid(), pastDate, Now); + + snapshot.Date.Should().Be(pastDate); + } + + #endregion + + #region Seal + + [Fact] + public void Seal_UnsealedSnapshot_SetsSealedAt() + { + var snapshot = new DailySnapshot(Guid.NewGuid(), Today, Now); + + snapshot.Seal(Now); + + snapshot.IsSealed.Should().BeTrue(); + snapshot.SealedAt.Should().Be(Now); + } + + [Fact] + public void Seal_AlreadySealedSnapshot_IsNoOp() + { + var snapshot = new DailySnapshot(Guid.NewGuid(), Today, Now); + snapshot.Seal(Now); + var originalSealedAt = snapshot.SealedAt; + + var laterTime = Now.AddHours(2); + snapshot.Seal(laterTime); + + snapshot.SealedAt.Should().Be(originalSealedAt); + snapshot.IsSealed.Should().BeTrue(); + } + + [Fact] + public void Seal_MultipleTimes_PreservesOriginalTimestamp() + { + var snapshot = new DailySnapshot(Guid.NewGuid(), Today, Now); + snapshot.Seal(Now); + + for (int i = 1; i <= 5; i++) + { + snapshot.Seal(Now.AddMinutes(i)); + } + + snapshot.SealedAt.Should().Be(Now); + } + + #endregion + + #region IsSealed + + [Fact] + public void IsSealed_NewSnapshot_ReturnsFalse() + { + var snapshot = new DailySnapshot(Guid.NewGuid(), Today, Now); + + snapshot.IsSealed.Should().BeFalse(); + } + + [Fact] + public void IsSealed_SealedSnapshot_ReturnsTrue() + { + var snapshot = new DailySnapshot(Guid.NewGuid(), Today, Now); + snapshot.Seal(Now); + + snapshot.IsSealed.Should().BeTrue(); + } + + #endregion +}