using Microsoft.EntityFrameworkCore; using PagerParser.Bart; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using System.Timers; namespace PagerParser { public partial class PagerContext { public DbSet BartMembers { get; set; } public DbSet BartAvailabilityRecords { get; set; } } } namespace PagerParser.Bart { public enum BartStatus { NotAvailable = -1, Clear = 0, Available = 1, AtPremises = 2, EmergencyOnly = 3, Delayed = 4 } [Index(nameof(MemberName))] public record BartMember { [JsonIgnore] [Key] public int BartMemberId { get; set; } public string MemberName { get; set; } } public record BartAvailabilityRecord { [JsonIgnore] [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int BartAvailabilityRecordId { get; set; } public DateTime Timestamp { get; set; } public BartMember Member { get; set; } public BartStatus Status { get; set; } public bool IsDefault { get; set; } public DateTime? ModifiedOn { get; set; } public BartMember? ModifiedBy { get; set; } public DateTime? CreatedOn { get; set; } public BartMember? CreatedBy { get; set; } } public class BartService : IHostedService { private const string AvailabilityEndpointUrl = @"https://bartapp.net/webapp/webservice/WebsiteService.svc/GetUserAvailabilityList"; private readonly TimeSpan FetchInterval = TimeSpan.FromMinutes(5); private IConfiguration config; private ILogger logger; private IServiceProvider serviceProvider; private string loginToken; private HttpClient httpClient; private System.Timers.Timer fetchTimer; public BartService( IConfiguration config, ILogger logger, IServiceProvider serviceProvider) { this.config = config; this.logger = logger; this.serviceProvider = serviceProvider; fetchTimer = new() { Interval = FetchInterval.TotalMilliseconds }; fetchTimer.Elapsed += OnFetchTimer; } public async Task FetchDayAsync(int groupId, DateOnly day) { using var scope = serviceProvider.CreateScope(); using var db = scope.ServiceProvider.GetRequiredService(); logger.LogDebug($"Fetching BART availability status for {day}"); // Prepare API request var request = new BartAvailabilityRequest() { RelatedDay = day.ToString("yyyy-MM-dd"), LoginToken = loginToken, PermissionLevel = 2, UserQualFilterIds = [], AvailabilityGroupFilterId = groupId.ToString(), GroupId = groupId.ToString(), Length = 0 }; // Fetch the data HttpResponseMessage response; try { response = await httpClient.PostAsJsonAsync( AvailabilityEndpointUrl, request, new JsonSerializerOptions() { PropertyNamingPolicy = null }); } catch(Exception e) { logger.LogError(e, "Error fetching availability status from BART"); return; } BartAvailabilityResponse responseData; try { responseData = (await response.Content .ReadFromJsonAsync())!; } catch(Exception e) { logger.LogError(e, "Error parsing BART JSON response"); return; } // Parse out a list of users from the response var users = responseData.GetUserAvailabilityListResult.List .Select(e => new BartMember() { BartMemberId = e.UserId, MemberName = e.MemberName.Trim() }) .ToArray(); var existingUsers = db.BartMembers .Select(m => m.BartMemberId) .ToArray(); // Add new users to the DB and update the names of existing ones try { foreach(var user in users) { if(existingUsers.Contains(user.BartMemberId)) db.Update(user); else db.Add(user); } await db.SaveChangesAsync(); } catch(Exception e) { logger.LogError(e, "Error saving BART members to the DB"); return; } // Fetch the most recent status for each member returned by BART // across the requested time span. We'll use this to check if a // member's status has changed since the last fetch, and if an // entry reflecting the modified availability status needs to be // recorded in the DB. // // The reason we do this, is to achieve a more granular availability // history reflecting updates to each hour. BART's availability // endpoint will only tell us when a member last updated the status // for an entire day, so by comparing each hour to the last fetch, // we can build a history of exactly which hourly slots have changed // and when. var existingRecords = db.BartAvailabilityRecords .Include(r => r.Member) .Where(r => users.Select(u => u.BartMemberId).Contains(r.Member.BartMemberId)) .GroupBy(r => new { r.Member.BartMemberId, r.Timestamp }) .Select(g => g.OrderBy(r => r.Timestamp).Last()) .ToArray(); LinkedList availabilityRecords = new(); foreach(var entry in responseData.GetUserAvailabilityListResult.List) { try { for(int hour = 0; hour < 24; hour++) { var timestamp = new DateTime( DateOnly.FromDateTime(entry.RelatedDay), new TimeOnly(hour, 0), DateTimeKind.Local); var status = (BartStatus) entry.GetType() .GetProperty($"Block{hour + 1}")! .GetValue(entry)!; var record = new BartAvailabilityRecord() { Timestamp = timestamp.ToUniversalTime(), Member = users.First(u => u.BartMemberId == entry.UserId), Status = status, IsDefault = entry.AvailabilityId < 0, ModifiedOn = entry.ModifiedOn, ModifiedBy = users.FirstOrDefault(u => u.MemberName == entry.ModifiedBy), CreatedBy = users.FirstOrDefault(u => u.MemberName == entry.CreatedBy) }; // If the availability for this hour is present in the // database and hasn't changed since the last fetch, // skip it var exists = existingRecords .Where(r => r.Member.BartMemberId == record.Member.BartMemberId) .Where(r => r.Timestamp == record.Timestamp) .Where(r => r.Status == record.Status) .Any(); if(exists) continue; // Optionally set the create/modify time if // one is present in the fetched record if(entry.ModifiedOn is not null) record.ModifiedOn = DateTime.SpecifyKind((DateTime) entry.ModifiedOn, DateTimeKind.Utc); if(entry.CreatedOn is not null) record.CreatedOn = DateTime.SpecifyKind((DateTime) entry.CreatedOn, DateTimeKind.Utc); availabilityRecords.AddLast(record); } } catch(Exception e) { logger.LogError(e, "Unknown error occurred whilst parsing availability entries"); return; } } try { await db.BartAvailabilityRecords.AddRangeAsync(availabilityRecords); await db.SaveChangesAsync(); } catch(Exception e) { logger.LogError(e, "Error committing availability entries to the DB"); return; } } private async void OnFetchTimer(object? sender, ElapsedEventArgs e) { // TODO: remove hardcoded group ID await FetchDayAsync(1366, DateOnly.FromDateTime(DateTime.Now)); } public async Task StartAsync(CancellationToken cancellationToken) { logger.LogInformation("BART service starting..."); loginToken = config.GetValue("PagerParser:Bart:Token"); httpClient = new(); fetchTimer.Start(); } public Task StopAsync(CancellationToken cancellationToken) { logger.LogInformation("BART service stopping..."); fetchTimer.Stop(); loginToken = null; httpClient.Dispose(); return Task.CompletedTask; } private record BartAvailabilityRequest { public string RelatedDay { get; set; } public string LoginToken { get; set; } public int PermissionLevel { get; set; } public string[] UserQualFilterIds { get; set; } public string AvailabilityGroupFilterId { get; set; } public string GroupId { get; set; } [JsonPropertyName("length")] public int Length { get; set; } } private record BartAvailabilityResponse { public BartAvailabilityResponseResult GetUserAvailabilityListResult { get; set; } } private record BartAvailabilityResponseResult { public List List { get; set; } } private record BartAvailabilityResponseEntry { public int AvailabilityId { get; set; } [JsonConverter(typeof(BartDateTimeConverter))] public DateTime RelatedDay { get; set; } public string MemberName { get; set; } [JsonConverter(typeof(BartNullableDateTimeConverter))] public DateTime? CreatedOn { get; set; } public string? CreatedBy { get; set; } [JsonConverter(typeof(BartNullableDateTimeConverter))] public DateTime? ModifiedOn { get; set; } public string? ModifiedBy { get; set; } public int UserId { get; set; } public int Block1 { get; set; } public int Block2 { get; set; } public int Block3 { get; set; } public int Block4 { get; set; } public int Block5 { get; set; } public int Block6 { get; set; } public int Block7 { get; set; } public int Block8 { get; set; } public int Block9 { get; set; } public int Block10 { get; set; } public int Block11 { get; set; } public int Block12 { get; set; } public int Block13 { get; set; } public int Block14 { get; set; } public int Block15 { get; set; } public int Block16 { get; set; } public int Block17 { get; set; } public int Block18 { get; set; } public int Block19 { get; set; } public int Block20 { get; set; } public int Block21 { get; set; } public int Block22 { get; set; } public int Block23 { get; set; } public int Block24 { get; set; } } private class BartDateTimeConverter : JsonConverter { private const string Format = "yyyy-MM-dd HH:mm:ss"; public override DateTime Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { return DateTime.ParseExact(reader.GetString()!, Format, null); } public override void Write( Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) { writer.WriteStringValue(value.ToString(Format)); } } private class BartNullableDateTimeConverter : JsonConverter { private const string Format = "yyyy-MM-dd HH:mm:ss"; public override DateTime? Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { string? value = reader.GetString(); if(string.IsNullOrEmpty(value)) return null; return DateTime.ParseExact(value, Format, null); } public override void Write( Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) { writer.WriteStringValue(value?.ToString(Format)); } } } }