feat(calendar): add application date integrations

This commit is contained in:
cesnimda
2026-08-31 22:24:38 +02:00
parent caf486dceb
commit 1152e05687
10 changed files with 547 additions and 2 deletions
@@ -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);
+44 -1
View File
@@ -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));
+29
View File
@@ -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>
);
}
+28
View File
@@ -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 }} /> },