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); }