Skip to content

Commit bda0066

Browse files
mpauloskyCopilot
andauthored
feat: Polish Sprint v0.3.0 — release workflow, restore command, assignee field, Redis caching (#125)
Closes #121 Closes #122 Closes #123 Closes #124 ## Polish Sprint v0.3.0 Changes ### A1 — Release workflow consolidation - Added `gh release create` step to `squad-milestone-release.yml` ### A2 — RestoreIssueCommand - New `RestoreIssueCommand` + sealed handler - Admin-only Restore button on issue Details page - 5 unit tests ### A3 — Assignee field - Added `Assignee (UserInfo)` to `Issue` model; `Assignee (UserDto)` to `IssueDto` - Updated `IssueMapper`, `IssueConfiguration` (EF Core MongoDB `OwnsOne`) - Fixed `BulkAssignCommand` + `UndoBulkOperationCommand` ### A4 — Redis distributed caching - `AnalyticsService` refactored from `IMemoryCache` → `IDistributedCache` + JSON - Redis in production; `MemoryDistributedCache` fallback for test environments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3443e8a commit bda0066

25 files changed

Lines changed: 746 additions & 214 deletions

.github/workflows/squad-milestone-release.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,15 @@ jobs:
8787
8888
git tag -a "$TAG" -m "$MSG"
8989
git push origin "$TAG"
90-
echo "✅ Tagged $TAG — squad-release.yml will create the GitHub Release"
90+
echo "✅ Tagged $TAG"
91+
92+
- name: Create GitHub Release
93+
env:
94+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
95+
TAG: ${{ steps.next.outputs.tag }}
96+
run: |
97+
gh release create "$TAG" \
98+
--generate-notes \
99+
--title "Release $TAG" \
100+
--verify-tag
101+
echo "✅ GitHub Release $TAG created"

.squad/plans/v0.3.0-options.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# IssueTrackerApp — v0.3.0 Work Options
2+
3+
**Created:** 2026-03-31
4+
**Status:** 📋 Pending — ready to implement when team picks up
5+
6+
---
7+
8+
## Current State (as of v0.2.0)
9+
10+
- ~85% feature-complete; core flows implemented (Issues, Comments, Categories, Statuses, Dashboard, Analytics, Bulk Ops, Attachments, Email, SignalR)
11+
- Pre-push gates, CI/CD, and dev workflow are solid
12+
- No open GitHub issues
13+
14+
---
15+
16+
## Known Gaps (Bugs / Incomplete Features)
17+
18+
| Gap | Owner Signal | Effort |
19+
| ---------------------------------------------------------------------------------------------------------------- | ------------- | ------ |
20+
| ❌ Redis not wired up (provisioned in Aspire, `IMemoryCache` only) | Sam + Boromir | Medium |
21+
| ⚠️ Issue Assignee conflates `Author` field — no dedicated field on Issue model | Sam + Aragorn | Medium |
22+
| ❌ No Restore/Unarchive command for archived issues | Sam + Legolas | Small |
23+
| ❌ Release workflow: `gh release create` not in `squad-milestone-release.yml` (documented decision, not yet done) | Boromir | Small |
24+
25+
---
26+
27+
## Option A — "Polish Sprint" (Fix Known Gaps)
28+
29+
**Goal:** Close all known gaps before adding new features.
30+
31+
### Tasks Option A
32+
33+
- Add dedicated `AssigneeId`/`AssigneeInfo` field to `Issue` model
34+
- Update `BulkAssignCommand` to use new `AssigneeInfo` field instead of `Author`
35+
- Implement `RestoreIssueCommand` + UI button for unarchiving
36+
- Consolidate release workflow — add `gh release create` step to `squad-milestone-release.yml`
37+
- Wire Redis distributed caching (replace `IMemoryCache` with `IDistributedCache`)
38+
- Update tests for all of the above
39+
40+
**Who:** Sam, Aragorn, Legolas, Boromir, Gimli
41+
**Outcome:** v0.3.0 — production-hardened, no known gaps
42+
43+
---
44+
45+
## Option B — "Redis Distributed Caching"
46+
47+
**Goal:** Wire Redis into the app for real scalability.
48+
49+
### Tasks Option B
50+
51+
- Register `IDistributedCache` backed by Redis in `Program.cs` / Aspire
52+
- Replace `AnalyticsService` `IMemoryCache` with `IDistributedCache`
53+
- Cache categories and statuses (read-heavy, slow-changing)
54+
- Update `InMemoryBulkOperationQueue` / `InMemoryUndoService` for distributed awareness
55+
- Integration tests for cache invalidation
56+
57+
**Who:** Sam + Boromir
58+
**Outcome:** App scales horizontally; Redis actually earns its place in the stack
59+
60+
---
61+
62+
## Option C — "Issue Voting & Prioritization"
63+
64+
**Goal:** Let users upvote issues to surface community priorities.
65+
66+
### Tasks Option C
67+
68+
- Add `Votes` (int) + `VotedBy` (list of userIds) to `Issue` model
69+
- `VoteIssueCommand` / `UnvoteIssueCommand` with idempotency
70+
- Sort-by-votes on issues list; "Top Voted" quick filter
71+
- Vote count badge on issue cards
72+
- SignalR broadcast when vote threshold crossed
73+
- bUnit + integration tests
74+
75+
**Who:** Sam, Legolas, Gimli
76+
**Outcome:** Community-driven prioritization; distinct product differentiator
77+
78+
---
79+
80+
## Option D — "Admin User Management"
81+
82+
**Goal:** Let admins manage users and roles directly in the app.
83+
84+
### Tasks Option D
85+
86+
- Auth0 Management API integration (list users, assign/remove roles)
87+
- New admin page: `/admin/users` with user list, role badges, edit modal
88+
- `UserManagementService` wrapping Auth0 Management API
89+
- Audit log of role changes
90+
- Strict Admin-only policy + tests
91+
92+
**Who:** Gandalf, Sam, Legolas, Gimli
93+
**Outcome:** Full admin control loop without needing Auth0 dashboard
94+
95+
---
96+
97+
## Option E — "Issue Labels / Tags"
98+
99+
**Goal:** Free-form tagging on top of Category/Status.
100+
101+
### Tasks Option E
102+
103+
- Add `Labels` (`List<string>`) to `Issue` model
104+
- Tag suggestions from existing labels (autocomplete)
105+
- Filter by label on issues list (URL param `label=`)
106+
- Label management admin page or inline creation
107+
- Architecture tests updated for new feature slice
108+
109+
**Who:** Sam, Legolas, Gimli
110+
**Outcome:** Richer classification; especially useful once issue volume grows
111+
112+
---
113+
114+
## Option F — "v0.3.0 Sprint Planning Ceremony"
115+
116+
**Goal:** Run a formal plan ceremony (Aragorn leads) to define the next milestone.
117+
118+
### Tasks Option F
119+
120+
- Review all options above as a team
121+
- Create GitHub Milestone `v0.3.0`
122+
- File sprint issues with labels, assignees, and acceptance criteria
123+
- Aragorn + Ralph own the board
124+
125+
**Who:** Aragorn (lead), Ralph (board)
126+
**Outcome:** Structured sprint with clear deliverables for all team members
127+
128+
---
129+
130+
## Recommendation
131+
132+
Start with **Option A (Polish Sprint)** + **Option B (Redis)** to close documented technical debt.
133+
Then run **Option F (Sprint Ceremony)** to formally charter v0.3.0 with community features from Options C, D, or E.

Directory.Packages.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<!-- MongoDB -->
1111
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.5" />
1212
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
13+
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.5" />
1314
<PackageVersion Include="MongoDB.Bson" Version="3.7.0" />
1415
<PackageVersion Include="MongoDB.Driver" Version="3.7.0" />
1516
<PackageVersion Include="MongoDB.EntityFrameworkCore" Version="10.0.1" />
@@ -64,4 +65,4 @@
6465
<PackageVersion Include="AspNetCore.HealthChecks.MongoDb" Version="9.0.0" />
6566
<PackageVersion Include="AspNetCore.HealthChecks.Redis" Version="9.0.0" />
6667
</ItemGroup>
67-
</Project>
68+
</Project>

src/Domain/DTOs/IssueDto.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ public record IssueDto(
2626
bool Archived,
2727
UserDto ArchivedBy,
2828
bool ApprovedForRelease,
29-
bool Rejected)
29+
bool Rejected,
30+
UserDto Assignee)
3031
{
3132
/// <summary>
3233
/// Initializes a new instance of the <see cref="IssueDto" /> record.
@@ -44,7 +45,8 @@ public IssueDto(Issue issue) : this(
4445
issue.Archived,
4546
UserMapper.ToDto(issue.ArchivedBy),
4647
issue.ApprovedForRelease,
47-
issue.Rejected)
48+
issue.Rejected,
49+
UserMapper.ToDto(issue.Assignee))
4850
{
4951
}
5052

@@ -60,5 +62,6 @@ public IssueDto(Issue issue) : this(
6062
false,
6163
UserDto.Empty,
6264
false,
63-
false);
65+
false,
66+
UserDto.Empty);
6467
}

src/Domain/Features/Issues/Commands/Bulk/BulkAssignCommand.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,13 @@ internal async Task<Result<BulkOperationResult>> ProcessBulkAssignAsync(
9797

9898
var issue = existingResult.Value;
9999

100-
// Store snapshot for undo (Author serves as the assignee in this model)
100+
// Store snapshot for undo — capture current Assignee before overwriting
101101
undoSnapshots.Add(new IssueUndoSnapshot(
102102
issue.Id.ToString(),
103103
BulkOperationType.Assignment,
104-
new AssignmentSnapshot(UserMapper.ToDto(issue.Author))));
104+
new AssignmentSnapshot(UserMapper.ToDto(issue.Assignee))));
105105

106-
// Note: Using Author field for assignment as the model doesn't have a separate Assignee
107-
// In a real scenario, you'd want to add an Assignee field to the Issue model
108-
issue.Author = UserMapper.ToInfo(request.Assignee);
106+
issue.Assignee = UserMapper.ToInfo(request.Assignee);
109107
issue.DateModified = DateTime.UtcNow;
110108

111109
var updateResult = await _repository.UpdateAsync(issue, cancellationToken);

src/Domain/Features/Issues/Commands/Bulk/UndoBulkOperationCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public async Task<Result<BulkOperationResult>> Handle(
9999
case BulkOperationType.Assignment:
100100
if (snapshot.PreviousState is AssignmentSnapshot assignmentSnapshot)
101101
{
102-
issue.Author = UserMapper.ToInfo(assignmentSnapshot.PreviousAssignee);
102+
issue.Assignee = UserMapper.ToInfo(assignmentSnapshot.PreviousAssignee);
103103
}
104104
break;
105105

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// =======================================================
2+
// Copyright (c) 2026. All rights reserved.
3+
// File Name : RestoreIssueCommand.cs
4+
// Company : mpaulosky
5+
// Author : Matthew Paulosky
6+
// Solution Name : IssueTrackerApp
7+
// Project Name : Domain
8+
// =======================================================
9+
10+
using Domain.Abstractions;
11+
12+
namespace Domain.Features.Issues.Commands;
13+
14+
/// <summary>
15+
/// Command to restore (unarchive) a previously archived issue.
16+
/// </summary>
17+
public record RestoreIssueCommand(string Id) : IRequest<Result<bool>>;
18+
19+
/// <summary>
20+
/// Handler for restoring (unarchiving) an issue.
21+
/// </summary>
22+
public sealed class RestoreIssueCommandHandler : IRequestHandler<RestoreIssueCommand, Result<bool>>
23+
{
24+
private readonly IRepository<Issue> _repository;
25+
private readonly ILogger<RestoreIssueCommandHandler> _logger;
26+
27+
public RestoreIssueCommandHandler(
28+
IRepository<Issue> repository,
29+
ILogger<RestoreIssueCommandHandler> logger)
30+
{
31+
_repository = repository;
32+
_logger = logger;
33+
}
34+
35+
public async Task<Result<bool>> Handle(RestoreIssueCommand request, CancellationToken cancellationToken)
36+
{
37+
_logger.LogInformation("Restoring archived issue with ID: {IssueId}", request.Id);
38+
39+
var existingResult = await _repository.GetByIdAsync(request.Id, cancellationToken);
40+
41+
if (existingResult.Failure || existingResult.Value is null)
42+
{
43+
_logger.LogWarning("Issue not found with ID: {IssueId}", request.Id);
44+
return Result.Fail<bool>("Issue not found", ResultErrorCode.NotFound);
45+
}
46+
47+
var issue = existingResult.Value;
48+
49+
if (!issue.Archived)
50+
{
51+
_logger.LogWarning("Issue {IssueId} is not archived; restore skipped", request.Id);
52+
return Result.Fail<bool>("Issue is not archived", ResultErrorCode.Validation);
53+
}
54+
55+
issue.Archived = false;
56+
issue.ArchivedBy = UserInfo.Empty;
57+
issue.DateModified = DateTime.UtcNow;
58+
59+
var result = await _repository.UpdateAsync(issue, cancellationToken);
60+
61+
if (result.Failure)
62+
{
63+
_logger.LogError("Failed to restore issue: {Error}", result.Error);
64+
return Result.Fail<bool>(result.Error ?? "Failed to restore issue", result.ErrorCode);
65+
}
66+
67+
_logger.LogInformation("Successfully restored issue with ID: {IssueId}", request.Id);
68+
return Result.Ok(true);
69+
}
70+
}

src/Domain/Mappers/IssueMapper.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ public static IssueDto ToDto(Issue? issue)
3636
issue.Archived,
3737
UserMapper.ToDto(issue.ArchivedBy),
3838
issue.ApprovedForRelease,
39-
issue.Rejected);
39+
issue.Rejected,
40+
UserMapper.ToDto(issue.Assignee));
4041
}
4142

4243
/// <summary>

src/Domain/Models/Issue.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ public class Issue
9696
/// </value>
9797
public UserInfo ArchivedBy { get; set; } = UserInfo.Empty;
9898

99+
/// <summary>
100+
/// Gets or sets the user assigned to this issue.
101+
/// </summary>
102+
/// <value>
103+
/// The assigned user, or <see cref="UserInfo.Empty" /> if unassigned.
104+
/// </value>
105+
public UserInfo Assignee { get; set; } = UserInfo.Empty;
106+
99107
/// <summary>
100108
/// Gets or sets a value indicating whether [approved for release].
101109
/// </summary>

src/Persistence.MongoDb/Configurations/IssueConfiguration.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ public void Configure(EntityTypeBuilder<Issue> builder)
3636
ab.Property(u => u.Email).HasElementName("email");
3737
});
3838

39+
builder.OwnsOne(i => i.Assignee, a =>
40+
{
41+
a.Property(u => u.Id).HasElementName("id");
42+
a.Property(u => u.Name).HasElementName("name");
43+
a.Property(u => u.Email).HasElementName("email");
44+
});
45+
3946
builder.OwnsOne(i => i.Category, c =>
4047
{
4148
c.Property(cat => cat.Id).HasElementName("id");

0 commit comments

Comments
 (0)