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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,21 @@ CLI and MCP leave create/update surfaces support dry-run. Dry-run performs the s
validation and payload preparation, returns the proposed request, and must not call
`CreateLeaveAsync` or `UpdateLeaveAsync`.

Create answers with an empty body, so `LeaveCreateService.ApplyAsync` re-reads the entry through
`LeaveLookup.FindCreatedAsync` (UPCOMING then PAST, matched on leave type, note, all-day flag and
dates — plus the slot to the minute for a partial day, since two partial-day requests on one day
differ only by their times) and both surfaces return
`{"success":true,"leaveId":"<guid>","leave":{...}}`. A missing or ambiguous match still reports
success, with `leave: null` and a `warning` whose recovery is `tp leave list --filter ALL` — never a
second create, which would duplicate the request. The read-back swallows every failure except the
caller's own cancellation: a completed write is reported as a success, because surfacing it as an
error is what invites a duplicate submission.

Date comparison is deliberately asymmetric. All-day entries are compared on the calendar date only
(the server normalises their times and may echo the day in its own offset); partial-day entries
prefer the offset-free `startDateWithoutOffset`/`endDateWithoutOffset` values and otherwise compare
instants shifted into the offset the request was built in.

The cancel endpoint (`PUT /api/leave/{id}/cancel`) requires `LeaveId` (Guid) and `CancellationReason` in the request body.
It returns as soon as the server accepts the request: the entry reads `PendingCancellation`
and only becomes `Cancelled` minutes later, so `tp leave cancel` never claims the request is
Expand Down
13 changes: 10 additions & 3 deletions src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,19 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Settings
return 0;
}

await _createService.ApplyAsync(plan, cancellationToken);
var result = await _createService.ApplyAsync(plan, cancellationToken);

if (settings.Json)
OutputHelper.WriteJson(new { success = true });
{
OutputHelper.WriteJson(result);
}
else
OutputHelper.WriteSuccess("Leave request created");
{
OutputHelper.WriteSuccess(
$"Leave request created{(result.LeaveId is not null ? $" (ID: {result.LeaveId})" : "")}");
if (result.Warning is not null)
OutputHelper.WriteWarning(result.Warning);
}

return 0;
}
Expand Down
39 changes: 36 additions & 3 deletions src/SSW.TimePro.Cli/Features/Leave/LeaveCreateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ public sealed record LeaveCreateOptions(

public sealed record LeaveCreatePlan(CreateLeaveRequest Request, string TypeLabel);

/// <summary>
/// Outcome of a leave create, shared by the CLI <c>--json</c> path and the MCP tool.
/// <see cref="Leave"/> is the entry re-read after the write, because POST /api/leave/ answers
/// with an empty body.
/// </summary>
public sealed record LeaveCreateResult
{
public bool Success { get; init; } = true;
public string? LeaveId { get; init; }
public LeaveEntry? Leave { get; init; }
public string? Warning { get; init; }
}

public sealed class LeaveCreateValidationException(string message) : Exception(message);

/// <summary>
Expand All @@ -25,8 +38,13 @@ public sealed class LeaveCreateValidationException(string message) : Exception(m
public sealed class LeaveCreateService
{
private readonly ITimeProApiClient _api;
private readonly LeaveLookup _lookup;

public LeaveCreateService(ITimeProApiClient api) => _api = api;
public LeaveCreateService(ITimeProApiClient api, LeaveLookup lookup)
{
_api = api;
_lookup = lookup;
}

public async Task<LeaveCreatePlan> PrepareAsync(
string employeeId,
Expand Down Expand Up @@ -128,8 +146,23 @@ public async Task<LeaveCreatePlan> PrepareAsync(
return new LeaveCreatePlan(request, options.Type);
}

public Task ApplyAsync(LeaveCreatePlan plan, CancellationToken ct = default) =>
_api.CreateLeaveAsync(plan.Request, ct);
public async Task<LeaveCreateResult> ApplyAsync(LeaveCreatePlan plan, CancellationToken ct = default)
{
await _api.CreateLeaveAsync(plan.Request, ct);

var matches = await _lookup.FindCreatedAsync(plan.Request, ct);
if (matches.Count == 1)
return new LeaveCreateResult { LeaveId = matches[0].Id, Leave = matches[0] };

// The request was submitted, so the recovery is always to look it up — creating again
// would duplicate it.
var count = matches.Count == 0 ? "no entry matches" : $"{matches.Count} entries match";
return new LeaveCreateResult
{
Warning = $"Leave request created, but the new entry could not be identified ({count}). "
+ "Find it with: tp leave list --filter ALL. Do not create it again."
};
}

private async Task<int?> ResolveLeaveTypeAsync(string typeInput, CancellationToken ct)
{
Expand Down
110 changes: 110 additions & 0 deletions src/SSW.TimePro.Cli/Features/Leave/LeaveLookup.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using SSW.TimePro.Cli.Infrastructure.ApiClient;
using SSW.TimePro.Cli.Shared.Models;

Expand Down Expand Up @@ -36,4 +37,113 @@ public sealed class LeaveLookup

return null;
}

/// <summary>
/// Locates a freshly created leave request, which the create endpoint answers with an empty
/// body. Returns every entry matching the submitted dates, type and note so an ambiguous
/// result stays ambiguous to the caller.
/// </summary>
public async Task<IReadOnlyList<LeaveEntry>> FindCreatedAsync(
CreateLeaveRequest request,
CancellationToken ct = default)
{
try
{
foreach (var filter in new[] { LeaveListService.Upcoming, LeaveListService.Past })
{
var matches = await MatchesAsync(filter, request, ct);
if (matches.Count > 0)
return matches;
}
Comment on lines +51 to +56
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
// A completed write is reported as success, full stop: any read-back failure that
// surfaced as an error would invite a duplicate submission.
}

return [];
}

private async Task<List<LeaveEntry>> MatchesAsync(
string filter,
CreateLeaveRequest request,
CancellationToken ct)
{
var matches = new List<LeaveEntry>();
var pageNumber = 1;

while (true)
{
var response = await _api.GetLeaveAsync(filter, pageNumber, PageSize, request.RequestedEmpId, ct);
var page = response?.Leaves;
if (page is not null)
matches.AddRange(page.Items.Where(entry => Matches(entry, request)));

if (page is null || pageNumber >= page.TotalPages)
return matches;

pageNumber++;
Comment on lines +74 to +84
}
}

private static bool Matches(LeaveEntry entry, CreateLeaveRequest request) =>
entry.AllDay == request.AllDay
&& entry.LeaveType?.Id == request.LeaveTypeId
&& string.Equals(entry.Note?.Trim() ?? "", request.Note?.Trim() ?? "", StringComparison.Ordinal)
&& (request.AllDay
? SameDay(entry.StartDateLocal ?? entry.StartDate, request.StartDate)
&& SameDay(entry.EndDateLocal ?? entry.EndDate, request.EndDate)
// Two partial-day requests on one day differ only by their slot, so comparing the
// dates alone would return the wrong entry's id.
: SameSlot(entry.StartDateLocal, entry.StartDate, request.StartDate)
&& SameSlot(entry.EndDateLocal, entry.EndDate, request.EndDate));

/// <summary>
/// All-day requests are compared on the calendar date only, never as instants: the server
/// normalises their times and may render the day in its own offset.
/// </summary>
private static bool SameDay(string? left, string? right) =>
DatePart(left) is { } day && day == DatePart(right);

private static string? DatePart(string? value) =>
value is null ? null : value.Split('T')[0];

/// <summary>
/// One end of a partial-day range, to the minute. The offset-free value is preferred;
/// otherwise the two are compared as instants, because the server may echo the same moment in
/// its own zone.
/// </summary>
private static bool SameSlot(string? entryLocal, string? entryOffset, string requestValue)
{
if (ParseOffset(requestValue) is not { } requested)
return false;

if (entryLocal is not null)
return ToMinute(entryLocal) is { } local && local == Minute(requested);

return ParseOffset(entryOffset) is { } entry
&& Minute(entry.ToOffset(requested.Offset)) == Minute(requested);
}

private static string? ToMinute(string value) =>
DatePart(value) is { } day && TimePart(value) is { } time ? $"{day}T{time}" : null;

private static DateTimeOffset? ParseOffset(string? value) =>
DateTimeOffset.TryParse(
value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)
? parsed
: null;

private static string Minute(DateTimeOffset value) =>
value.ToString("yyyy-MM-ddTHH:mm", CultureInfo.InvariantCulture);

private static string? TimePart(string? value)
{
if (value?.Split('T') is not [_, var time])
return null;

var parts = time.Split(':');
return parts.Length >= 2 ? $"{parts[0]}:{parts[1]}" : null;
}
}
4 changes: 2 additions & 2 deletions src/SSW.TimePro.Cli/Features/Mcp/Tools/LeaveMcpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ public async Task<string> CreateLeave(
if (dryRun)
return JsonSerializer.Serialize(new { dryRun = true, request = plan.Request }, JsonOpts);

await _leaveCreateService.ApplyAsync(plan, ct);
return JsonSerializer.Serialize(new { success = true }, JsonOpts);
var result = await _leaveCreateService.ApplyAsync(plan, ct);
return JsonSerializer.Serialize(result, JsonOpts);
}
catch (LeaveCreateValidationException ex)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"leave": {
"allDay": true,
"approvedBy": "bob@northwind.example",
"createdAt": "2026-03-01T09:00:00\u002B10:00",
"daysAway": 1,
"endDate": "2026-04-01T23:59:00\u002B10:00",
"id": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa",
"leaveType": {
"id": 1,
"isActive": true,
"name": "Annual Leave"
},
"length": 1,
"note": "Family day",
"optionalEmp": [
"bob@northwind.example"
],
"requestedEmpId": "BOB",
"startDate": "2026-04-01T00:00:00\u002B10:00",
"status": 1,
"statusName": "Pending",
"updatedAt": "2026-03-02T09:00:00\u002B10:00",
"userEndTime": "18:00",
"userStartTime": "09:00"
},
"leaveId": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa",
"success": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"leave": {
"allDay": true,
"approvedBy": "bob@northwind.example",
"cancellationReason": null,
"createdAt": "2026-03-01T09:00:00\u002B10:00",
"daysAway": 1,
"endDate": "2026-04-01T23:59:00\u002B10:00",
"endDateWithoutOffset": null,
"id": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa",
"leaveType": {
"id": 1,
"isActive": true,
"name": "Annual Leave"
},
"length": 1,
"note": "Family day",
"optionalEmp": [
"bob@northwind.example"
],
"requestedEmpId": "BOB",
"startDate": "2026-04-01T00:00:00\u002B10:00",
"startDateWithoutOffset": null,
"status": 1,
"statusName": "Pending",
"timeLessOverride": null,
"updatedAt": "2026-03-02T09:00:00\u002B10:00",
"userEndTime": "18:00",
"userStartTime": "09:00"
},
"leaveId": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa",
"success": true,
"warning": null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"leave": {
"allDay": true,
"approvedBy": "bob@northwind.example",
"cancellationReason": null,
"createdAt": "2026-03-01T09:00:00\u002B10:00",
"daysAway": 1,
"endDate": "2026-04-01T23:59:00\u002B10:00",
"endDateWithoutOffset": null,
"id": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa",
"leaveType": {
"id": 1,
"isActive": true,
"name": "Annual Leave"
},
"length": 1,
"note": "Family day",
"optionalEmp": [
"bob@northwind.example"
],
"requestedEmpId": "BOB",
"startDate": "2026-04-01T00:00:00\u002B10:00",
"startDateWithoutOffset": null,
"status": 1,
"statusName": "Pending",
"timeLessOverride": null,
"updatedAt": "2026-03-02T09:00:00\u002B10:00",
"userEndTime": "18:00",
"userStartTime": "09:00"
},
"leaveId": "0f2b7e7a-1111-4444-8888-aaaaaaaaaaaa",
"success": true,
"warning": null
}
Loading