diff --git a/JobTrackerApi.Tests/ApplicationCalendarControllerTests.cs b/JobTrackerApi.Tests/ApplicationCalendarControllerTests.cs new file mode 100644 index 0000000..06577ec --- /dev/null +++ b/JobTrackerApi.Tests/ApplicationCalendarControllerTests.cs @@ -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(); + google.Setup(service => service.GetConnectionAsync("owner-1", It.IsAny())) + .ReturnsAsync(new GmailConnection { OwnerUserId = "owner-1", Scope = GmailOAuthService.CalendarScope }); + var microsoft = new Mock(); + microsoft.Setup(service => service.GetConnectionAsync("owner-1", It.IsAny())) + .ReturnsAsync(new MicrosoftGraphConnection { OwnerUserId = "owner-1", Scope = "Mail.Read" }); + var controller = CreateController(db, "owner-1", google.Object, microsoft.Object); + + var value = Assert.IsType(Assert.IsType((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(); + google.Setup(service => service.CreateCalendarEventAsync("owner-1", It.IsAny(), It.IsAny())) + .Callback((_, request, _) => sent = request) + .ReturnsAsync(new ExternalCalendarEventResult("event-1", "https://calendar.google.test/event-1")); + var controller = CreateController(db, "owner-1", google.Object, Mock.Of()); + + var response = await controller.CreateEvent(job.Id, new("google", "follow-up"), default); + + var value = Assert.IsType(Assert.IsType(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(MockBehavior.Strict); + var controller = CreateController(db, "owner-1", google.Object, Mock.Of()); + + var response = await controller.CreateEvent(job.Id, new("google", "follow-up"), default); + + Assert.IsType(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(), Mock.Of()); + + var response = Assert.IsType(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(); + current.SetupGet(service => service.UserId).Returns(owner); + return new JobTrackerContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options, current.Object); + } + + private static async Task 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(); + current.SetupGet(service => service.UserId).Returns(owner); + return new ApplicationCalendarController(db, current.Object, google, microsoft); + } +} diff --git a/JobTrackerApi/Controllers/ApplicationCalendarController.cs b/JobTrackerApi/Controllers/ApplicationCalendarController.cs new file mode 100644 index 0000000..9aeaa26 --- /dev/null +++ b/JobTrackerApi/Controllers/ApplicationCalendarController.cs @@ -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> 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> 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 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 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); +} diff --git a/JobTrackerApi/Models/ExternalCalendarEvent.cs b/JobTrackerApi/Models/ExternalCalendarEvent.cs new file mode 100644 index 0000000..e1d219a --- /dev/null +++ b/JobTrackerApi/Models/ExternalCalendarEvent.cs @@ -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); diff --git a/JobTrackerApi/Services/GmailOAuthService.cs b/JobTrackerApi/Services/GmailOAuthService.cs index 5570189..a5a5d94 100644 --- a/JobTrackerApi/Services/GmailOAuthService.cs +++ b/JobTrackerApi/Services/GmailOAuthService.cs @@ -25,6 +25,7 @@ public interface IGmailOAuthService Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken); Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); Task SendAsync(string ownerUserId, GmailSendRequest request, CancellationToken cancellationToken); + Task 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 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)); diff --git a/JobTrackerApi/Services/MicrosoftGraphOAuthService.cs b/JobTrackerApi/Services/MicrosoftGraphOAuthService.cs index 9df2f08..d4e4078 100644 --- a/JobTrackerApi/Services/MicrosoftGraphOAuthService.cs +++ b/JobTrackerApi/Services/MicrosoftGraphOAuthService.cs @@ -21,6 +21,7 @@ public interface IMicrosoftGraphOAuthService Task> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken); Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); Task SendAsync(string ownerUserId, MicrosoftGraphSendRequest request, CancellationToken cancellationToken); + Task 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 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)); diff --git a/docs/operations/calendar-integrations.md b/docs/operations/calendar-integrations.md new file mode 100644 index 0000000..f3e926b --- /dev/null +++ b/docs/operations/calendar-integrations.md @@ -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. diff --git a/job-tracker-ui/src/application-calendar.test.tsx b/job-tracker-ui/src/application-calendar.test.tsx new file mode 100644 index 0000000..59477bb --- /dev/null +++ b/job-tracker-ui/src/application-calendar.test.tsx @@ -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; + +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(); + + 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(); + + 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(); + + expect(await screen.findByText("Reconnect your calendar account to approve calendar access.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Reconnect" })).toHaveAttribute("href", "/settings"); +}); diff --git a/job-tracker-ui/src/components/ApplicationCalendarActions.tsx b/job-tracker-ui/src/components/ApplicationCalendarActions.tsx new file mode 100644 index 0000000..9b68218 --- /dev/null +++ b/job-tracker-ui/src/components/ApplicationCalendarActions.tsx @@ -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(null); + const [busy, setBusy] = useState(""); + const [error, setError] = useState(""); + const [created, setCreated] = useState(null); + + useEffect(() => { + let active = true; + api.get("/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(`/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 {t("calendarNoDates")}; + if (!status) return {t("loading")}; + + return ( + + {t("calendarHelp")} + {(status.google.connected && !status.google.writable) || (status.microsoft.connected && !status.microsoft.writable) ? ( + {t("calendarReconnect")}}>{t("calendarPermissionNeeded")} + ) : null} + {error ? {error} : null} + {created ? ( + }>{t("calendarOpen")} : undefined}> + {t("calendarCreated")} + + ) : null} + {rows.map((row) => ( + + {row.title} + {status.google.writable ? : null} + {status.microsoft.writable ? : null} + + + ))} + + ); +} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 3ec623d..2bf4556 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -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", diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index d275c6a..c09ca96 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -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: }, { id: "tasks", title: t("workspaceChecklist"), content: }, + { id: "calendar", title: t("calendarTitle"), content: }, { id: "timeline", title: t("workspaceActivityHistory"), content: }, { id: "documents", title: t("workspaceDocuments"), content: }, { id: "communication", title: t("workspaceCommunication"), content: },