feat(calendar): add application date integrations
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user