feat(calendar): add application date integrations
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class ApplicationCalendarControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Status_distinguishes_connection_from_calendar_consent()
|
||||
{
|
||||
await using var db = CreateDb("owner-1");
|
||||
var google = new Mock<IGmailOAuthService>();
|
||||
google.Setup(service => service.GetConnectionAsync("owner-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailConnection { OwnerUserId = "owner-1", Scope = GmailOAuthService.CalendarScope });
|
||||
var microsoft = new Mock<IMicrosoftGraphOAuthService>();
|
||||
microsoft.Setup(service => service.GetConnectionAsync("owner-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MicrosoftGraphConnection { OwnerUserId = "owner-1", Scope = "Mail.Read" });
|
||||
var controller = CreateController(db, "owner-1", google.Object, microsoft.Object);
|
||||
|
||||
var value = Assert.IsType<ApplicationCalendarController.CalendarStatusDto>(Assert.IsType<OkObjectResult>((await controller.Status(default)).Result).Value);
|
||||
|
||||
Assert.True(value.Google.Connected);
|
||||
Assert.True(value.Google.Writable);
|
||||
Assert.True(value.Microsoft.Connected);
|
||||
Assert.False(value.Microsoft.Writable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creates_follow_up_in_selected_connected_calendar_with_stable_job_context()
|
||||
{
|
||||
await using var db = CreateDb("owner-1");
|
||||
var job = await SeedJobAsync(db, "owner-1");
|
||||
ExternalCalendarEventRequest? sent = null;
|
||||
var google = new Mock<IGmailOAuthService>();
|
||||
google.Setup(service => service.CreateCalendarEventAsync("owner-1", It.IsAny<ExternalCalendarEventRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, ExternalCalendarEventRequest, CancellationToken>((_, request, _) => sent = request)
|
||||
.ReturnsAsync(new ExternalCalendarEventResult("event-1", "https://calendar.google.test/event-1"));
|
||||
var controller = CreateController(db, "owner-1", google.Object, Mock.Of<IMicrosoftGraphOAuthService>());
|
||||
|
||||
var response = await controller.CreateEvent(job.Id, new("google", "follow-up"), default);
|
||||
|
||||
var value = Assert.IsType<ApplicationCalendarController.CalendarEventDto>(Assert.IsType<OkObjectResult>(response.Result).Value);
|
||||
Assert.Equal("google", value.Provider);
|
||||
Assert.Equal("event-1", value.Id);
|
||||
Assert.NotNull(sent);
|
||||
Assert.Equal("Follow up: Backend Engineer at Acme", sent.Summary);
|
||||
Assert.Equal(job.FollowUpAt, sent.StartsAtUtc.UtcDateTime);
|
||||
Assert.False(sent.AllDay);
|
||||
Assert.Equal(64, sent.StableId.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_another_owners_job_without_calling_provider()
|
||||
{
|
||||
await using var db = CreateDb("owner-1");
|
||||
var job = await SeedJobAsync(db, "owner-2");
|
||||
var google = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
||||
var controller = CreateController(db, "owner-1", google.Object, Mock.Of<IMicrosoftGraphOAuthService>());
|
||||
|
||||
var response = await controller.CreateEvent(job.Id, new("google", "follow-up"), default);
|
||||
|
||||
Assert.IsType<NotFoundResult>(response.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Downloads_portable_all_day_deadline_event_without_provider_credentials()
|
||||
{
|
||||
await using var db = CreateDb("owner-1");
|
||||
var job = await SeedJobAsync(db, "owner-1");
|
||||
var controller = CreateController(db, "owner-1", Mock.Of<IGmailOAuthService>(), Mock.Of<IMicrosoftGraphOAuthService>());
|
||||
|
||||
var response = Assert.IsType<FileContentResult>(await controller.DownloadEvent(job.Id, "deadline", default));
|
||||
var content = System.Text.Encoding.UTF8.GetString(response.FileContents);
|
||||
|
||||
Assert.Equal("text/calendar; charset=utf-8", response.ContentType);
|
||||
Assert.Contains("DTSTART;VALUE=DATE:20260915", content);
|
||||
Assert.Contains("DTEND;VALUE=DATE:20260916", content);
|
||||
Assert.Contains("SUMMARY:Application deadline: Backend Engineer at Acme", content);
|
||||
}
|
||||
|
||||
private static JobTrackerContext CreateDb(string owner)
|
||||
{
|
||||
var current = new Mock<ICurrentUserService>();
|
||||
current.SetupGet(service => service.UserId).Returns(owner);
|
||||
return new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options, current.Object);
|
||||
}
|
||||
|
||||
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db, string owner)
|
||||
{
|
||||
var company = new Company { OwnerUserId = owner, Name = "Acme" };
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = owner,
|
||||
Company = company,
|
||||
JobTitle = "Backend Engineer",
|
||||
Status = "Applied",
|
||||
FollowUpAt = new DateTime(2026, 9, 2, 10, 30, 0, DateTimeKind.Utc),
|
||||
Deadline = new DateTime(2026, 9, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
Location = "Oslo"
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
private static ApplicationCalendarController CreateController(JobTrackerContext db, string owner, IGmailOAuthService google, IMicrosoftGraphOAuthService microsoft)
|
||||
{
|
||||
var current = new Mock<ICurrentUserService>();
|
||||
current.SetupGet(service => service.UserId).Returns(owner);
|
||||
return new ApplicationCalendarController(db, current.Object, google, microsoft);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/calendar")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class ApplicationCalendarController(
|
||||
JobTrackerContext db,
|
||||
ICurrentUserService currentUser,
|
||||
IGmailOAuthService google,
|
||||
IMicrosoftGraphOAuthService microsoft) : ControllerBase
|
||||
{
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<CalendarStatusDto>> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
var owner = RequiredOwner();
|
||||
var googleConnection = await google.GetConnectionAsync(owner, cancellationToken);
|
||||
var microsoftConnection = await microsoft.GetConnectionAsync(owner, cancellationToken);
|
||||
return Ok(new CalendarStatusDto(
|
||||
new CalendarProviderStatusDto(googleConnection is not null, GmailOAuthService.HasCalendarScope(googleConnection?.Scope)),
|
||||
new CalendarProviderStatusDto(microsoftConnection is not null, MicrosoftGraphOAuthService.HasCalendarScope(microsoftConnection?.Scope))));
|
||||
}
|
||||
|
||||
[HttpPost("jobs/{jobId:int}/events")]
|
||||
public async Task<ActionResult<CalendarEventDto>> CreateEvent(int jobId, [FromBody] CreateCalendarEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var owner = RequiredOwner();
|
||||
var job = await LoadJobAsync(jobId, owner, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
if (!TryBuildEvent(job, owner, request.Kind, out var calendarEvent, out var validationError))
|
||||
return ValidationProblem(validationError);
|
||||
|
||||
try
|
||||
{
|
||||
var provider = request.Provider?.Trim().ToLowerInvariant();
|
||||
var result = provider switch
|
||||
{
|
||||
"google" => await google.CreateCalendarEventAsync(owner, calendarEvent!, cancellationToken),
|
||||
"microsoft" => await microsoft.CreateCalendarEventAsync(owner, calendarEvent!, cancellationToken),
|
||||
_ => throw new ArgumentException("Choose Google Calendar or Outlook Calendar.")
|
||||
};
|
||||
return Ok(new CalendarEventDto(provider!, result.Id, result.WebUrl));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return ValidationProblem(ex.Message);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Conflict(new ProblemDetails { Title = "Calendar permission required", Detail = ex.Message, Status = StatusCodes.Status409Conflict });
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return Problem("The calendar provider is temporarily unavailable.", statusCode: StatusCodes.Status502BadGateway);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("jobs/{jobId:int}/events.ics")]
|
||||
public async Task<IActionResult> DownloadEvent(int jobId, [FromQuery] string? kind, CancellationToken cancellationToken)
|
||||
{
|
||||
var owner = RequiredOwner();
|
||||
var job = await LoadJobAsync(jobId, owner, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
if (!TryBuildEvent(job, owner, kind, out var calendarEvent, out var validationError))
|
||||
return ValidationProblem(validationError);
|
||||
|
||||
var value = calendarEvent!;
|
||||
var start = value.AllDay ? $"DTSTART;VALUE=DATE:{value.StartsAtUtc:yyyyMMdd}" : $"DTSTART:{value.StartsAtUtc.UtcDateTime:yyyyMMdd'T'HHmmss'Z'}";
|
||||
var end = value.AllDay ? $"DTEND;VALUE=DATE:{value.EndsAtUtc:yyyyMMdd}" : $"DTEND:{value.EndsAtUtc.UtcDateTime:yyyyMMdd'T'HHmmss'Z'}";
|
||||
var ics = string.Join("\r\n", new[]
|
||||
{
|
||||
"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Jobjakt//Application Calendar//EN", "CALSCALE:GREGORIAN",
|
||||
"BEGIN:VEVENT", $"UID:{value.StableId}@jobs.cesnimda.uk", $"DTSTAMP:{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}",
|
||||
start, end, $"SUMMARY:{EscapeIcs(value.Summary)}",
|
||||
$"DESCRIPTION:{EscapeIcs(value.Description)}", $"LOCATION:{EscapeIcs(value.Location)}",
|
||||
"END:VEVENT", "END:VCALENDAR", ""
|
||||
});
|
||||
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", $"jobjakt-{job.Id}-{NormalizeKind(kind)}.ics");
|
||||
}
|
||||
|
||||
private Task<JobApplication?> LoadJobAsync(int jobId, string owner, CancellationToken cancellationToken) =>
|
||||
db.JobApplications.Include(job => job.Company)
|
||||
.FirstOrDefaultAsync(job => job.Id == jobId && job.OwnerUserId == owner && !job.IsDeleted, cancellationToken);
|
||||
|
||||
private static bool TryBuildEvent(JobApplication job, string owner, string? requestedKind, out ExternalCalendarEventRequest? result, out string error)
|
||||
{
|
||||
var kind = NormalizeKind(requestedKind);
|
||||
DateTimeOffset start;
|
||||
DateTimeOffset end;
|
||||
bool allDay;
|
||||
string label;
|
||||
if (kind == "follow-up" && job.FollowUpAt is { } followUp)
|
||||
{
|
||||
start = ToUtcOffset(followUp);
|
||||
end = start.AddMinutes(30);
|
||||
allDay = false;
|
||||
label = "Follow up";
|
||||
}
|
||||
else if (kind == "deadline" && job.Deadline is { } deadline)
|
||||
{
|
||||
start = new DateTimeOffset(DateTime.SpecifyKind(deadline.Date, DateTimeKind.Utc));
|
||||
end = start.AddDays(1);
|
||||
allDay = true;
|
||||
label = "Application deadline";
|
||||
}
|
||||
else
|
||||
{
|
||||
result = null;
|
||||
error = kind == "follow-up" ? "Set a follow-up date before adding it to a calendar." : "This application has no deadline to add.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var company = job.Company?.Name?.Trim();
|
||||
var summary = string.IsNullOrWhiteSpace(company) ? $"{label}: {job.JobTitle}" : $"{label}: {job.JobTitle} at {company}";
|
||||
var stableId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{owner}|{job.Id}|{kind}"))).ToLowerInvariant();
|
||||
result = new ExternalCalendarEventRequest(stableId, summary, job.NextAction, job.Location, start, end, allDay);
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToUtcOffset(DateTime value) => value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => new DateTimeOffset(value),
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc))
|
||||
};
|
||||
|
||||
private static string NormalizeKind(string? kind) => string.Equals(kind?.Trim(), "deadline", StringComparison.OrdinalIgnoreCase) ? "deadline" : "follow-up";
|
||||
private static string EscapeIcs(string? value) => (value ?? string.Empty).Replace("\\", "\\\\").Replace(";", "\\;").Replace(",", "\\,").Replace("\r\n", "\\n").Replace("\r", "\\n").Replace("\n", "\\n");
|
||||
private string RequiredOwner() => currentUser.UserId ?? throw new UnauthorizedAccessException("Authentication required.");
|
||||
|
||||
public sealed record CalendarProviderStatusDto(bool Connected, bool Writable);
|
||||
public sealed record CalendarStatusDto(CalendarProviderStatusDto Google, CalendarProviderStatusDto Microsoft);
|
||||
public sealed record CreateCalendarEventRequest(string? Provider, string? Kind);
|
||||
public sealed record CalendarEventDto(string Provider, string? Id, string? WebUrl);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed record ExternalCalendarEventRequest(
|
||||
string StableId,
|
||||
string Summary,
|
||||
string? Description,
|
||||
string? Location,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
bool AllDay);
|
||||
|
||||
public sealed record ExternalCalendarEventResult(string? Id, string? WebUrl);
|
||||
@@ -25,6 +25,7 @@ public interface IGmailOAuthService
|
||||
Task<IReadOnlyList<GmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
|
||||
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
Task<GmailSendResult> SendAsync(string ownerUserId, GmailSendRequest request, CancellationToken cancellationToken);
|
||||
Task<ExternalCalendarEventResult> CreateCalendarEventAsync(string ownerUserId, ExternalCalendarEventRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record GmailOAuthExchangeResult(string GmailAddress);
|
||||
@@ -47,7 +48,8 @@ internal sealed class GmailTokenResponse
|
||||
public sealed class GmailOAuthService : IGmailOAuthService
|
||||
{
|
||||
public const string SendScope = "https://www.googleapis.com/auth/gmail.send";
|
||||
private const string Scope = $"openid email profile https://www.googleapis.com/auth/gmail.readonly {SendScope}";
|
||||
public const string CalendarScope = "https://www.googleapis.com/auth/calendar.events";
|
||||
private const string Scope = $"openid email profile https://www.googleapis.com/auth/gmail.readonly {SendScope} {CalendarScope}";
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
@@ -419,6 +421,47 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
||||
public static bool HasSendScope(string? scope) =>
|
||||
!string.IsNullOrWhiteSpace(scope) && scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains(SendScope, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public async Task<ExternalCalendarEventResult> CreateCalendarEventAsync(string ownerUserId, ExternalCalendarEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.GmailConnections.AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null || !HasCalendarScope(connection.Scope))
|
||||
throw new InvalidOperationException("Reconnect Google and approve calendar access before adding events.");
|
||||
|
||||
var payload = request.AllDay
|
||||
? JsonSerializer.Serialize(new
|
||||
{
|
||||
summary = request.Summary,
|
||||
description = request.Description,
|
||||
location = request.Location,
|
||||
start = new { date = request.StartsAtUtc.UtcDateTime.ToString("yyyy-MM-dd") },
|
||||
end = new { date = request.EndsAtUtc.UtcDateTime.ToString("yyyy-MM-dd") }
|
||||
})
|
||||
: JsonSerializer.Serialize(new
|
||||
{
|
||||
summary = request.Summary,
|
||||
description = request.Description,
|
||||
location = request.Location,
|
||||
start = new { dateTime = request.StartsAtUtc.ToString("O") },
|
||||
end = new { dateTime = request.EndsAtUtc.ToString("O") }
|
||||
});
|
||||
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
using var response = await client.PutAsync(
|
||||
$"https://www.googleapis.com/calendar/v3/calendars/primary/events/{Uri.EscapeDataString(request.StableId)}",
|
||||
new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
return new ExternalCalendarEventResult(
|
||||
document.RootElement.TryGetProperty("id", out var id) ? id.GetString() : null,
|
||||
document.RootElement.TryGetProperty("htmlLink", out var link) ? link.GetString() : null);
|
||||
}
|
||||
|
||||
public static bool HasCalendarScope(string? scope) =>
|
||||
!string.IsNullOrWhiteSpace(scope) && scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains(CalendarScope, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static void ValidateSendRequest(string to, string subject, string bodyText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(to)) throw new ArgumentException("Recipient is required.", nameof(to));
|
||||
|
||||
@@ -21,6 +21,7 @@ public interface IMicrosoftGraphOAuthService
|
||||
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken);
|
||||
Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
Task<MicrosoftGraphSendResult> SendAsync(string ownerUserId, MicrosoftGraphSendRequest request, CancellationToken cancellationToken);
|
||||
Task<ExternalCalendarEventResult> CreateCalendarEventAsync(string ownerUserId, ExternalCalendarEventRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record MicrosoftGraphOAuthExchangeResult(string MailAddress);
|
||||
@@ -47,7 +48,8 @@ internal sealed class MicrosoftGraphTokenResponse
|
||||
public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
||||
{
|
||||
public const string SendScope = "https://graph.microsoft.com/Mail.Send";
|
||||
private const string Scope = $"openid email profile offline_access https://graph.microsoft.com/Mail.Read {SendScope}";
|
||||
public const string CalendarScope = "https://graph.microsoft.com/Calendars.ReadWrite";
|
||||
private const string Scope = $"openid email profile offline_access https://graph.microsoft.com/Mail.Read {SendScope} {CalendarScope}";
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
@@ -340,6 +342,47 @@ public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
||||
string.Equals(value, "Mail.Send", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public async Task<ExternalCalendarEventResult> CreateCalendarEventAsync(string ownerUserId, ExternalCalendarEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.MicrosoftGraphConnections.AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null || !HasCalendarScope(connection.Scope))
|
||||
throw new InvalidOperationException("Reconnect Outlook and approve calendar access before adding events.");
|
||||
|
||||
var startsAt = request.AllDay ? request.StartsAtUtc.UtcDateTime.Date : request.StartsAtUtc.UtcDateTime;
|
||||
var endsAt = request.AllDay ? request.EndsAtUtc.UtcDateTime.Date : request.EndsAtUtc.UtcDateTime;
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
subject = request.Summary,
|
||||
body = new { contentType = "Text", content = request.Description ?? string.Empty },
|
||||
location = new { displayName = request.Location ?? string.Empty },
|
||||
start = new { dateTime = startsAt.ToString("yyyy-MM-ddTHH:mm:ss"), timeZone = "UTC" },
|
||||
end = new { dateTime = endsAt.ToString("yyyy-MM-ddTHH:mm:ss"), timeZone = "UTC" },
|
||||
isAllDay = request.AllDay,
|
||||
transactionId = request.StableId
|
||||
});
|
||||
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
using var response = await client.PostAsync(
|
||||
"https://graph.microsoft.com/v1.0/me/events",
|
||||
new StringContent(payload, System.Text.Encoding.UTF8, "application/json"),
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
return new ExternalCalendarEventResult(
|
||||
document.RootElement.TryGetProperty("id", out var id) ? id.GetString() : null,
|
||||
document.RootElement.TryGetProperty("webLink", out var link) ? link.GetString() : null);
|
||||
}
|
||||
|
||||
public static bool HasCalendarScope(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope)) return false;
|
||||
return scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Any(value =>
|
||||
string.Equals(value, CalendarScope, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "Calendars.ReadWrite", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static void ValidateSendRequest(string to, string subject, string bodyText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(to)) throw new ArgumentException("Recipient is required.", nameof(to));
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Calendar integrations
|
||||
|
||||
Jobjakt can add an application's follow-up date or deadline to Google Calendar or Outlook Calendar. The same workspace panel always offers a portable `.ics` download, which requires no provider account.
|
||||
|
||||
## Provider configuration
|
||||
|
||||
Calendar access reuses the encrypted OAuth connections already used for Gmail and Microsoft Graph mail. No calendar tokens or credentials are stored separately.
|
||||
|
||||
### Google
|
||||
|
||||
1. Enable the Google Calendar API in the Google Cloud project used by `GMAIL_CLIENT_ID` / `GMAIL_CLIENT_SECRET`.
|
||||
2. Keep the existing Gmail OAuth callback URI registered.
|
||||
3. Reconnect Google from Settings. The consent request now includes `https://www.googleapis.com/auth/calendar.events`.
|
||||
|
||||
### Microsoft
|
||||
|
||||
1. In the Entra application used by `MICROSOFT_GRAPH_CLIENT_ID` / `MICROSOFT_GRAPH_CLIENT_SECRET`, add the delegated `Calendars.ReadWrite` permission.
|
||||
2. Keep the existing Microsoft Graph OAuth callback URI registered.
|
||||
3. Reconnect Outlook from Settings and grant the new permission.
|
||||
|
||||
Existing connections remain valid for mail. The application reports them as connected but not calendar-writable until the user reconnects and grants the new scope.
|
||||
|
||||
## Behaviour and rollback
|
||||
|
||||
- Calendar writes happen only after an explicit user action in an application workspace.
|
||||
- Jobjakt sends the job title, company, location, next-action text, and selected date; it never sends the CV or job description.
|
||||
- Google uses a deterministic event ID, and Microsoft receives a deterministic transaction ID, reducing duplicate events when a request is retried.
|
||||
- Disable or remove the provider's delegated calendar permission to stop direct writes. `.ics` download remains available.
|
||||
- No database migration is required.
|
||||
@@ -0,0 +1,45 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { api } from "./api";
|
||||
import ApplicationCalendarActions from "./components/ApplicationCalendarActions";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: { get: jest.fn(), post: jest.fn() },
|
||||
getApiErrorMessage: (_: unknown, fallback: string) => fallback,
|
||||
}));
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
mockedApi.get.mockResolvedValue({ data: { google: { connected: true, writable: true }, microsoft: { connected: false, writable: false } } } as any);
|
||||
mockedApi.post.mockResolvedValue({ data: { provider: "google", id: "event-1", webUrl: "https://calendar.google.test/event-1" } } as any);
|
||||
});
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
test("adds the selected application date to a writable connected calendar", async () => {
|
||||
render(<I18nProvider><ApplicationCalendarActions jobId={42} followUpAt="2026-09-02T10:30:00Z" deadline="2026-09-15T00:00:00Z" /></I18nProvider>);
|
||||
|
||||
const googleButtons = await screen.findAllByRole("button", { name: "Google Calendar" });
|
||||
fireEvent.click(googleButtons[0]);
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/calendar/jobs/42/events", { provider: "google", kind: "follow-up" }));
|
||||
expect(await screen.findByText("The event was added to your calendar.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Open event" })).toHaveAttribute("href", "https://calendar.google.test/event-1");
|
||||
});
|
||||
|
||||
test("keeps portable calendar download available without a provider connection", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { google: { connected: false, writable: false }, microsoft: { connected: false, writable: false } } } as any);
|
||||
render(<I18nProvider><ApplicationCalendarActions jobId={42} followUpAt={null} deadline="2026-09-15T00:00:00Z" /></I18nProvider>);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Download .ics" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Google Calendar" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("explains when an existing connection needs renewed calendar consent", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { google: { connected: true, writable: false }, microsoft: { connected: false, writable: false } } } as any);
|
||||
render(<I18nProvider><ApplicationCalendarActions jobId={42} followUpAt="2026-09-02T10:30:00Z" /></I18nProvider>);
|
||||
|
||||
expect(await screen.findByText("Reconnect your calendar account to approve calendar access.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Reconnect" })).toHaveAttribute("href", "/settings");
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Box, Button, CircularProgress, Stack, Typography } from "@mui/material";
|
||||
import CalendarMonthOutlinedIcon from "@mui/icons-material/CalendarMonthOutlined";
|
||||
import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type ProviderStatus = { connected: boolean; writable: boolean };
|
||||
type CalendarStatus = { google: ProviderStatus; microsoft: ProviderStatus };
|
||||
type EventKind = "follow-up" | "deadline";
|
||||
type CalendarEventResult = { provider: string; id?: string; webUrl?: string };
|
||||
|
||||
export default function ApplicationCalendarActions({ jobId, followUpAt, deadline }: { jobId: number; followUpAt?: string | null; deadline?: string | null }) {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = useState<CalendarStatus | null>(null);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [created, setCreated] = useState<CalendarEventResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.get<CalendarStatus>("/calendar/status")
|
||||
.then((response) => { if (active) setStatus(response.data); })
|
||||
.catch(() => { if (active) setStatus({ google: { connected: false, writable: false }, microsoft: { connected: false, writable: false } }); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
const create = async (provider: "google" | "microsoft", kind: EventKind) => {
|
||||
setBusy(`${provider}-${kind}`); setError(""); setCreated(null);
|
||||
try {
|
||||
const response = await api.post<CalendarEventResult>(`/calendar/jobs/${jobId}/events`, { provider, kind });
|
||||
setCreated(response.data);
|
||||
} catch (err) { setError(getApiErrorMessage(err, t("calendarCreateFailed"))); }
|
||||
finally { setBusy(""); }
|
||||
};
|
||||
|
||||
const download = async (kind: EventKind) => {
|
||||
setBusy(`ics-${kind}`); setError("");
|
||||
try {
|
||||
const response = await api.get(`/calendar/jobs/${jobId}/events.ics`, { params: { kind }, responseType: "blob" });
|
||||
const url = URL.createObjectURL(response.data);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url; anchor.download = `jobjakt-${jobId}-${kind}.ics`; anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) { setError(getApiErrorMessage(err, t("calendarDownloadFailed"))); }
|
||||
finally { setBusy(""); }
|
||||
};
|
||||
|
||||
const rows: Array<{ kind: EventKind; title: string }> = [
|
||||
...(followUpAt ? [{ kind: "follow-up" as const, title: t("calendarFollowUp") }] : []),
|
||||
...(deadline ? [{ kind: "deadline" as const, title: t("calendarDeadline") }] : []),
|
||||
];
|
||||
|
||||
if (rows.length === 0) return <Alert severity="info">{t("calendarNoDates")}</Alert>;
|
||||
if (!status) return <Stack direction="row" spacing={1} alignItems="center"><CircularProgress size={18} /><Typography variant="body2">{t("loading")}</Typography></Stack>;
|
||||
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="body2" color="text.secondary">{t("calendarHelp")}</Typography>
|
||||
{(status.google.connected && !status.google.writable) || (status.microsoft.connected && !status.microsoft.writable) ? (
|
||||
<Alert severity="info" action={<Button color="inherit" size="small" href="/settings">{t("calendarReconnect")}</Button>}>{t("calendarPermissionNeeded")}</Alert>
|
||||
) : null}
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
{created ? (
|
||||
<Alert severity="success" action={created.webUrl ? <Button color="inherit" size="small" href={created.webUrl} target="_blank" rel="noreferrer" endIcon={<OpenInNewIcon />}>{t("calendarOpen")}</Button> : undefined}>
|
||||
{t("calendarCreated")}
|
||||
</Alert>
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
<Box key={row.kind} sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, p: 1.25, border: 1, borderColor: "divider", borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 750, flex: "1 1 180px" }}>{row.title}</Typography>
|
||||
{status.google.writable ? <Button size="small" startIcon={<CalendarMonthOutlinedIcon />} disabled={Boolean(busy)} onClick={() => void create("google", row.kind)}>{t("calendarGoogle")}</Button> : null}
|
||||
{status.microsoft.writable ? <Button size="small" startIcon={<CalendarMonthOutlinedIcon />} disabled={Boolean(busy)} onClick={() => void create("microsoft", row.kind)}>{t("calendarOutlook")}</Button> : null}
|
||||
<Button size="small" startIcon={<DownloadOutlinedIcon />} disabled={Boolean(busy)} onClick={() => void download(row.kind)}>{t("calendarDownload")}</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -278,6 +278,20 @@ export const translations = {
|
||||
jobDiscoverySavedSearchesHelp: "Saved NAV searches are checked automatically. You will be notified when new vacancies appear.",
|
||||
jobDiscoveryAutomaticAlerts: "Automatic alerts",
|
||||
jobDiscoveryUpdateAlertsFailed: "The alert setting could not be updated. Try again.",
|
||||
calendarTitle: "Calendar",
|
||||
calendarHelp: "Add important application dates to a connected calendar, or download a portable calendar file.",
|
||||
calendarFollowUp: "Application follow-up",
|
||||
calendarDeadline: "Application deadline",
|
||||
calendarGoogle: "Google Calendar",
|
||||
calendarOutlook: "Outlook Calendar",
|
||||
calendarDownload: "Download .ics",
|
||||
calendarReconnect: "Reconnect",
|
||||
calendarPermissionNeeded: "Reconnect your calendar account to approve calendar access.",
|
||||
calendarCreated: "The event was added to your calendar.",
|
||||
calendarOpen: "Open event",
|
||||
calendarNoDates: "Set a follow-up date or application deadline to add it to a calendar.",
|
||||
calendarCreateFailed: "The calendar event could not be created.",
|
||||
calendarDownloadFailed: "The calendar file could not be downloaded.",
|
||||
jobDiscoverySavedName: "Search name",
|
||||
jobDiscoverySavedDefaultName: "Recent vacancies",
|
||||
jobDiscoverySaveSearch: "Save search",
|
||||
@@ -2680,6 +2694,20 @@ export const translations = {
|
||||
jobDiscoverySavedSearchesHelp: "Lagrede NAV-søk kontrolleres automatisk. Du får et varsel når nye stillinger dukker opp.",
|
||||
jobDiscoveryAutomaticAlerts: "Automatiske varsler",
|
||||
jobDiscoveryUpdateAlertsFailed: "Varslingsinnstillingen kunne ikke oppdateres. Prøv igjen.",
|
||||
calendarTitle: "Kalender",
|
||||
calendarHelp: "Legg viktige søknadsdatoer til i en tilkoblet kalender, eller last ned en kalenderfil.",
|
||||
calendarFollowUp: "Oppfølging av søknad",
|
||||
calendarDeadline: "Søknadsfrist",
|
||||
calendarGoogle: "Google Kalender",
|
||||
calendarOutlook: "Outlook-kalender",
|
||||
calendarDownload: "Last ned .ics",
|
||||
calendarReconnect: "Koble til på nytt",
|
||||
calendarPermissionNeeded: "Koble til kalenderkontoen på nytt for å godkjenne kalendertilgang.",
|
||||
calendarCreated: "Hendelsen ble lagt til i kalenderen.",
|
||||
calendarOpen: "Åpne hendelsen",
|
||||
calendarNoDates: "Angi en oppfølgingsdato eller søknadsfrist for å legge den til i en kalender.",
|
||||
calendarCreateFailed: "Kalenderhendelsen kunne ikke opprettes.",
|
||||
calendarDownloadFailed: "Kalenderfilen kunne ikke lastes ned.",
|
||||
jobDiscoverySavedName: "Navn på søket",
|
||||
jobDiscoverySavedDefaultName: "Nylige stillinger",
|
||||
jobDiscoverySaveSearch: "Lagre søk",
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getApiErrorMessage } from "../api";
|
||||
import Attachments from "../components/Attachments";
|
||||
import Correspondence from "../components/Correspondence";
|
||||
import ApplicationChecklist from "../components/ApplicationChecklist";
|
||||
import ApplicationCalendarActions from "../components/ApplicationCalendarActions";
|
||||
import {
|
||||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||||
} from "../components/ApplicationIntelligence";
|
||||
@@ -387,6 +388,7 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
|
||||
const panels = [
|
||||
{ id: "details", title: t("workspaceJobDetails"), content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
|
||||
{ id: "tasks", title: t("workspaceChecklist"), content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
|
||||
{ id: "calendar", title: t("calendarTitle"), content: <ApplicationCalendarActions jobId={jobId} followUpAt={overview.followUpAt} deadline={overview.deadline} /> },
|
||||
{ id: "timeline", title: t("workspaceActivityHistory"), content: <ApplicationTimeline jobId={jobId} /> },
|
||||
{ id: "documents", title: t("workspaceDocuments"), content: <Attachments jobId={jobId} /> },
|
||||
{ id: "communication", title: t("workspaceCommunication"), content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },
|
||||
|
||||
Reference in New Issue
Block a user