Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace Planora.BuildingBlocks.Application.Messaging.Events
{
/// <summary>
/// Raised by TodoApi when a single subtask is deleted. A subtask owns no branch of its own —
/// its only timeline footprint is the system comments it left in the PARENT task's branch
/// ("X added a subtask: …" / "X completed a subtask: …"). Collaboration consumes this and
/// soft-deletes exactly those announcement comments so the parent's branch stays clean. The
/// subtask title is carried so the consumer can match the deterministic sentence suffix
/// (the comment store keeps no structural link back to the subtask aggregate).
///
/// Note: deleting a whole parent task instead emits <see cref="TaskDeletedIntegrationEvent"/>,
/// which removes the entire branch (including any subtask announcements) in one shot.
/// </summary>
public sealed class SubtaskDeletedIntegrationEvent : IntegrationEvent
{
/// <summary>The parent task whose branch carries the announcement comments.</summary>
public Guid ParentTaskId { get; init; }

/// <summary>The deleted subtask's id (diagnostic / future correlation).</summary>
public Guid SubtaskId { get; init; }

public Guid ActorId { get; init; }

/// <summary>The deleted subtask's title — matches the system-comment sentence suffix.</summary>
public string Title { get; init; } = string.Empty;

public SubtaskDeletedIntegrationEvent(Guid parentTaskId, Guid subtaskId, Guid actorId, string title)
{
ParentTaskId = parentTaskId;
SubtaskId = subtaskId;
ActorId = actorId;
Title = title;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ public static class TaskActivityType
public const string Completed = "Completed";
public const string StartedWorking = "StartedWorking";
public const string Left = "Left";

// Subtask lifecycle surfaced in the PARENT task's branch (TaskId = parent id;
// Detail = subtask title).
public const string SubtaskCreated = "SubtaskCreated";
public const string SubtaskCompleted = "SubtaskCompleted";
}

/// <summary>
/// Raised by TodoApi on task lifecycle transitions (owner completes/starts/stops, worker
/// joins/leaves). Consumed by the Collaboration service to append a system comment to the
/// task's activity timeline. The sentence template lives in the consumer; this event only
/// carries the structured fact plus the actor's display name (freshest at publish time).
/// joins/leaves) and subtask create/complete. Consumed by the Collaboration service to append a
/// system comment to the task's activity timeline. The sentence template lives in the consumer;
/// this event only carries the structured fact plus the actor's display name (freshest at
/// publish time) and an optional <see cref="Detail"/> (e.g. the subtask title).
/// </summary>
public sealed class TaskActivityIntegrationEvent : IntegrationEvent
{
Expand All @@ -27,12 +33,16 @@ public sealed class TaskActivityIntegrationEvent : IntegrationEvent
/// <summary>One of <see cref="TaskActivityType"/>.</summary>
public string ActivityType { get; init; } = string.Empty;

public TaskActivityIntegrationEvent(Guid taskId, Guid actorId, string actorName, string activityType)
/// <summary>Optional context for the sentence, e.g. the subtask title. Null for plain task events.</summary>
public string? Detail { get; init; }

public TaskActivityIntegrationEvent(Guid taskId, Guid actorId, string actorName, string activityType, string? detail = null)
{
TaskId = taskId;
ActorId = actorId;
ActorName = actorName;
ActivityType = activityType;
Detail = detail;
}
}
}
263 changes: 263 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ await DatabaseStartup.EnsureReadyAsync(
await eventBus.SubscribeAsync<TaskDeletedIntegrationEvent, TaskDeletedEventConsumer>(app.Lifetime.ApplicationStopping);
logger.LogInformation("✅ Subscribed to TaskDeletedIntegrationEvent");

await eventBus.SubscribeAsync<SubtaskDeletedIntegrationEvent, SubtaskDeletedEventConsumer>(app.Lifetime.ApplicationStopping);
logger.LogInformation("✅ Subscribed to SubtaskDeletedIntegrationEvent");

await eventBus.SubscribeAsync<UserDeletedIntegrationEvent, UserDeletedEventConsumer>(app.Lifetime.ApplicationStopping);
logger.LogInformation("✅ Subscribed to UserDeletedIntegrationEvent");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public static IServiceCollection AddCollaborationApplication(this IServiceCollec
services.AddScoped<TaskCreatedEventConsumer>();
services.AddScoped<TaskActivityEventConsumer>();
services.AddScoped<TaskDeletedEventConsumer>();
services.AddScoped<SubtaskDeletedEventConsumer>();
services.AddScoped<UserDeletedEventConsumer>();

return services;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Planora.BuildingBlocks.Application.Messaging;
using Planora.BuildingBlocks.Application.Messaging.Events;
using Planora.Collaboration.Domain.Repositories;

namespace Planora.Collaboration.Application.Features.IntegrationEvents
{
/// <summary>
/// Removes a deleted subtask's announcement comments from the PARENT task's branch. A subtask
/// has no branch of its own, so deleting it must not wipe the parent timeline — only the
/// "added a subtask: …" / "completed a subtask: …" system comments it produced. Naturally
/// idempotent: a redelivered event finds no remaining matching comments.
/// </summary>
public sealed class SubtaskDeletedEventConsumer : IIntegrationEventHandler<SubtaskDeletedIntegrationEvent>
{
private readonly ICommentRepository _commentRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<SubtaskDeletedEventConsumer> _logger;

public SubtaskDeletedEventConsumer(
ICommentRepository commentRepository,
IUnitOfWork unitOfWork,
ILogger<SubtaskDeletedEventConsumer> logger)
{
_commentRepository = commentRepository;
_unitOfWork = unitOfWork;
_logger = logger;
}

public async Task HandleAsync(SubtaskDeletedIntegrationEvent @event, CancellationToken cancellationToken)
{
await _commentRepository.SoftDeleteSubtaskActivityAsync(
@event.ParentTaskId, @event.Title, @event.ActorId, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);

_logger.LogInformation(
"Soft-deleted subtask {SubtaskId} announcement comments in parent branch {ParentTaskId} (actor {ActorId})",
@event.SubtaskId, @event.ParentTaskId, @event.ActorId);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,19 @@ public TaskActivityEventConsumer(
public async Task HandleAsync(TaskActivityIntegrationEvent @event, CancellationToken cancellationToken)
{
var actorName = string.IsNullOrWhiteSpace(@event.ActorName) ? "Someone" : @event.ActorName;
var detail = @event.Detail?.Trim();

var text = @event.ActivityType switch
{
TaskActivityType.Completed => $"{actorName} completed the task",
TaskActivityType.StartedWorking => $"{actorName} started working on the task",
TaskActivityType.Left => $"{actorName} left the task",
TaskActivityType.SubtaskCreated => string.IsNullOrWhiteSpace(detail)
? $"{actorName} added a subtask"
: $"{actorName} added a subtask: {detail}",
TaskActivityType.SubtaskCompleted => string.IsNullOrWhiteSpace(detail)
? $"{actorName} completed a subtask"
: $"{actorName} completed a subtask: {detail}",
_ => null
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,13 @@ public interface ICommentRepository : IRepository<Comment>
Task<(IReadOnlyList<Comment> Items, int TotalCount)> GetPagedByTaskIdAsync(
Guid taskId, int pageNumber, int pageSize, CancellationToken ct = default);
Task SoftDeleteByTaskIdAsync(Guid taskId, Guid deletedBy, CancellationToken ct = default);

/// <summary>
/// Soft-deletes the subtask announcement system comments ("… added a subtask: {title}" /
/// "… completed a subtask: {title}") within a parent task's branch, used when that subtask
/// is deleted. Matches the deterministic sentence suffix produced by the activity consumer.
/// </summary>
Task SoftDeleteSubtaskActivityAsync(
Guid parentTaskId, string subtaskTitle, Guid deletedBy, CancellationToken ct = default);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,28 @@ public async Task SoftDeleteByTaskIdAsync(Guid taskId, Guid deletedBy, Cancellat
foreach (var comment in comments)
comment.MarkAsDeleted(deletedBy);
}

public async Task SoftDeleteSubtaskActivityAsync(
Guid parentTaskId, string subtaskTitle, Guid deletedBy, CancellationToken ct = default)
{
var title = (subtaskTitle ?? string.Empty).Trim();
if (title.Length == 0)
return;

// The activity consumer writes deterministic sentences ending with the subtask title,
// e.g. "Ann added a subtask: Buy milk". Match those exact suffixes within the parent's
// branch so we never touch a regular comment or a different subtask's announcement.
var addedSuffix = $"added a subtask: {title}";
var completedSuffix = $"completed a subtask: {title}";

// Load-then-update keeps parity with SoftDeleteByTaskIdAsync (InMemory-friendly).
var comments = await DbSet
.Where(c => c.TaskId == parentTaskId && c.IsSystemComment && !c.IsDeleted &&
(c.Content.EndsWith(addedSuffix) || c.Content.EndsWith(completedSuffix)))
.ToListAsync(ct);

foreach (var comment in comments)
comment.MarkAsDeleted(deletedBy);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
using Planora.Todo.Application.Features.Todos.Queries.GetTodoById;
using Planora.Todo.Application.Features.Todos.Commands.JoinTodo;
using Planora.Todo.Application.Features.Todos.Commands.LeaveTodo;
using Planora.Todo.Application.Features.Todos.Commands.CreateSubtask;
using Planora.Todo.Application.Features.Todos.Queries.GetSubtasks;

namespace Planora.Todo.Api.Controllers
{
Expand Down Expand Up @@ -39,9 +41,12 @@ public async Task<ActionResult<PagedResult<TodoItemDto>>> GetTodos(
[FromQuery] string? status = null,
[FromQuery] Guid? categoryId = null,
[FromQuery] bool? isCompleted = null,
// Subtasks are excluded from lists by default; the dashboard stats fetch opts in so
// completed subtasks still count toward weekly statistics.
[FromQuery] bool includeSubtasks = false,
CancellationToken cancellationToken = default)
{
var query = new GetUserTodosQuery(null, pageNumber, pageSize, status, categoryId, isCompleted);
var query = new GetUserTodosQuery(null, pageNumber, pageSize, status, categoryId, isCompleted, includeSubtasks);
var result = await _mediator.Send(query, cancellationToken);

return Ok(result);
Expand Down Expand Up @@ -77,6 +82,50 @@ public async Task<ActionResult<TodoItemDto>> GetTodoById(
return Ok(result.Value);
}

/// <summary>
/// Lists the subtasks (children) of a task. Visible to the owner, or to a friend for a
/// shared/public parent. Subtasks live only in the task's branch.
/// </summary>
[HttpGet("{id}/subtasks")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<TodoItemDto>>> GetSubtasks(
[FromRoute] Guid id,
CancellationToken cancellationToken = default)
{
var result = await _mediator.Send(new GetSubtasksQuery(id), cancellationToken);

if (result.IsFailure)
return NotFound(result.Error);

return Ok(result.Value);
}

/// <summary>
/// Creates a subtask under a task (owner-only). The subtask inherits the parent's
/// category, public flag and shared audience; it has its own title/priority and no dates.
/// </summary>
[HttpPost("{id}/subtasks")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TodoItemDto>> CreateSubtask(
[FromRoute] Guid id,
[FromBody] CreateSubtaskCommand command,
CancellationToken cancellationToken = default)
{
var createCommand = command with { ParentTodoId = id }; // parent comes from the route, never the body
var result = await _mediator.Send(createCommand, cancellationToken);

if (result.IsFailure)
return BadRequest(result.Error);

return CreatedAtAction(
nameof(GetTodoById),
new { id = result.Value!.Id },
result.Value);
}

[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
Expand Down
29 changes: 29 additions & 0 deletions Services/TodoApi/Planora.Todo.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,35 @@ await DatabaseStartup.EnsureReadyAsync(
}
}

// Subtasks allow up to 1500-character titles (a subtask's whole content is its
// title). The shared TodoItems.Title column historically was varchar(200); widen
// it on existing migration-built databases so long subtask titles persist. This
// is idempotent and metadata-only in PostgreSQL (a varchar length *increase*
// never rewrites the table), and guarded so it only runs while still too narrow.
// Fresh installs already get 1500 from the EF model (TodoItemConfiguration).
try
{
// The TodoItems table lives in the "todo" schema — qualify it explicitly
// (an unqualified name resolves against the search_path/public and fails).
await db.Database.ExecuteSqlRawAsync(@"
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'todo' AND table_name = 'TodoItems' AND column_name = 'Title'
AND character_maximum_length IS NOT NULL
AND character_maximum_length < 1500
) THEN
ALTER TABLE todo.""TodoItems"" ALTER COLUMN ""Title"" TYPE varchar(1500);
END IF;
END $$;", app.Lifetime.ApplicationStopping);
logger.LogInformation("✅ Ensured TodoItems.Title accommodates 1500-character subtask titles");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not reconcile TodoItems.Title column width (non-fatal)");
}

// Subscribe to Integration Events
logger.LogInformation("🔄 Subscribing to integration events...");
var eventBus = provider.GetRequiredService<IEventBus>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ public sealed record TodoItemDto
public bool IsWorking { get; init; }
public IReadOnlyList<Guid> WorkerUserIds { get; init; } = Array.Empty<Guid>();
public bool? IsCompletedByViewer { get; init; }

/// <summary>When set, this item is a subtask (child) of the given parent task.</summary>
public Guid? ParentTodoId { get; init; }
}

public class TodoItemMappingProfile : Profile
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Planora.BuildingBlocks.Domain;
using Planora.Todo.Application.DTOs;
using Planora.Todo.Domain.Enums;

namespace Planora.Todo.Application.Features.Todos.Commands.CreateSubtask
{
/// <summary>
/// Creates a subtask under a parent task. The subtask inherits the parent's category,
/// public flag and shared audience; it carries its own title, optional description and
/// priority, and never a due/expected date. Owner-only.
/// </summary>
public sealed record CreateSubtaskCommand(
Guid ParentTodoId,
string Title,
string? Description = null,
TodoPriority Priority = TodoPriority.Medium) : ICommand<Result<TodoItemDto>>;
}
Loading
Loading