diff --git a/backend/Dockerfile b/backend/Dockerfile index f0dfe09..4fa5927 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -13,4 +13,4 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 VOLUME /data WORKDIR /App COPY --from=build /App/out . -ENTRYPOINT ["dotnet", "SolicitorsApi.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "Solicitors.Api.dll"] \ No newline at end of file diff --git a/backend/SolicitorsApi.slnx b/backend/SolicitorsApi.slnx index f7accb8..4d0780e 100644 --- a/backend/SolicitorsApi.slnx +++ b/backend/SolicitorsApi.slnx @@ -1,4 +1,7 @@ + + + @@ -8,6 +11,4 @@ - - diff --git a/backend/SolicitorsApi/Program.cs b/backend/SolicitorsApi/Program.cs deleted file mode 100644 index c03d35d..0000000 --- a/backend/SolicitorsApi/Program.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.ComponentModel; -using Microsoft.AspNetCore.Mvc; -using Solicitors.CacheBuild; -using Solicitors.Core; -using Solicitors.Core.Misc; -using Solicitors.Core.Models; -using Solicitors.Data; -using Solicitors.Data.RepositorySetup.Sqlite; -using Solicitors.HtmlParsing; - - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddOpenApi(); -builder.Services.AddCore(); -builder.Services.AddData(new SqliteDbSetupOptions("demo")); -builder.Services.AddCacheBuild(); -builder.Services.AddHtmlParsing(); -builder.Services.AddHttpClient(); -builder.Services.AddCors(options => -{ - options.AddDefaultPolicy(policy => policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin()); -}); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - -app.UseHttpsRedirection(); -app.UseCors(); - -app.MapGet( - "/conveyancors", - ( - [FromServices] IReadOnlySolicitorService solicitorService, - CancellationToken cancellationToken, - [FromQuery] uint pageNumber = 1, - [FromQuery] uint pageSize = 20, - [FromQuery] string? nameFilter = null, - [FromQuery] decimal minRating = 0, - [FromQuery] string[]? cities = null, - [FromQuery] string ratingsProvider = "Solicitors.com", - [FromQuery] string? orderingType = null) => - { - IFilter? filter = null; - - if (!string.IsNullOrEmpty(nameFilter)) - filter = new WrapperFilter(filter, solicitor => solicitor.Name.Contains(nameFilter)); - - if (cities is not null && cities.Length > 0) - filter = new WrapperFilter(filter, solicitor => cities.Any(city => solicitor.Cities.Any(solCity => solCity.Name == city))); - - if (minRating > 0) - filter = new WrapperFilter( - filter, - solicitor => solicitor.Ratings.Any(rating => (rating.Provider == ratingsProvider) && (rating.Value / rating.Maximum >= minRating / 5.0m))); - - IComparer? ordering = orderingType switch - { - "rating-asc" => new RatingComparer(ratingsProvider, true), - "rating-desc" => new RatingComparer(ratingsProvider, false), - "alphabet-asc" => new NameComparer(true), - "alphabet-desc" => new NameComparer(false), - _ => null - }; - - return solicitorService.GetSolicitorSummariesAsync( - new Pagination(pageNumber, pageSize), - ratingsProvider, - filter, - ordering, - cancellationToken: cancellationToken); - } -); - -app.MapGet( - "/cities", - ( - [FromServices] IReadOnlyCitiesService citiesService, - CancellationToken cancellationToken) => - { - return citiesService.GetAllCitiesAsync(cancellationToken); - } -); - -app.MapGet( - "/conveyancors/{id}", - ( - [FromServices] IReadOnlySolicitorService solicitorService, - [FromRoute] Guid id, - CancellationToken cancellationToken - ) => solicitorService.GetSolicitorInfoByIdAsync(id, cancellationToken) -); - -app.MapGet( - "/ratingsProviders", - ( - [FromServices] IRatingsProviderService service, - CancellationToken cancellationToken - ) => - { - return service.GetRatingsProvidersAsync(cancellationToken); - } -); - -using (var scope = app.Services.CreateScope()) -{ - var solicitors = scope.ServiceProvider.GetRequiredService(); - var first = await solicitors - .GetSolicitorSummariesAsync( - new Pagination(1, 1), - "Solicitors.com", - cancellationToken: CancellationToken.None); - if (first.Data.Length == 0) - await scope.ServiceProvider - .GetRequiredService() - .RunFullImport(CancellationToken.None); -} - -app.Run(); - -public class RatingComparer(string ratingsProvider, bool ascending) : IComparer -{ - public int Compare(Solicitor? x, Solicitor? y) - { - var xRating = x?.Ratings?.FirstOrDefault(r => r.Provider == ratingsProvider); - var yRating = y?.Ratings?.FirstOrDefault(r => r.Provider == ratingsProvider); - - decimal compare; - - if (xRating is null) - compare = yRating is null ? 0 : 1; - else if (yRating is null) - compare = -1; - else - { - compare = (xRating.Value / xRating.Maximum) - (yRating.Value / yRating.Maximum); - - if (!ascending) - compare *= -1; - } - - if (compare >= 0) - return (int)Math.Ceiling(compare); - else - return (int)Math.Floor(compare); - - } -} - -public class NameComparer(bool ascending) : IComparer -{ - public int Compare(Solicitor? x, Solicitor? y) - { - int compare; - if (ReferenceEquals(x, y)) - compare = 0; - else if (y is null) - compare = 1; - else if (x is null) - compare = -1; - else - { - compare = string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); - - if (!ascending) - compare *= -1; - } - - return compare; - } -} \ No newline at end of file diff --git a/backend/SolicitorsApi/SolicitorsApi.http b/backend/SolicitorsApi/SolicitorsApi.http deleted file mode 100644 index a6c02e2..0000000 --- a/backend/SolicitorsApi/SolicitorsApi.http +++ /dev/null @@ -1,6 +0,0 @@ -@SolicitorsApi_HostAddress = http://localhost:5084 - -GET {{SolicitorsApi_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/backend/SolicitorsApi/appsettings.Development.json b/backend/SolicitorsApi/appsettings.Development.json deleted file mode 100644 index ff66ba6..0000000 --- a/backend/SolicitorsApi/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/backend/Tester/Program.cs b/backend/Tester/Program.cs deleted file mode 100644 index 1d6f986..0000000 --- a/backend/Tester/Program.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Solicitors.HtmlParsing; - -var _html = File.ReadAllText("/var/home/fox/main.html"); -var services = new ServiceCollection(); -services.AddHtmlParsing(); -var parser = services.BuildServiceProvider().GetRequiredService(); - -var htmlNodes = parser.ParseHtml(_html).ToArray(); -Console.ReadKey(); \ No newline at end of file diff --git a/backend/Tester/Tester.csproj b/backend/Tester/Tester.csproj deleted file mode 100644 index dd248af..0000000 --- a/backend/Tester/Tester.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - Exe - net10.0 - enable - enable - - - - - ..\..\..\..\..\..\..\..\var\home\fox\.dotnet\shared\Microsoft.AspNetCore.App\10.0.7\Microsoft.Extensions.DependencyInjection.Abstractions.dll - - - - - - - - - - - - diff --git a/backend/app/Solicitors.Api/Endpoints/CitiesEndpoints.cs b/backend/app/Solicitors.Api/Endpoints/CitiesEndpoints.cs new file mode 100644 index 0000000..76f3cc3 --- /dev/null +++ b/backend/app/Solicitors.Api/Endpoints/CitiesEndpoints.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using Solicitors.Core; + +namespace Solicitors.Api.Endpoints; + +public static class CitiesEndpoints +{ + public static void MapCities(this IEndpointRouteBuilder app) + { + app.MapGet( + "/cities", + ( + [FromServices] IReadOnlyCitiesService citiesService, + CancellationToken cancellationToken + ) => citiesService.GetAllCitiesAsync(cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/app/Solicitors.Api/Endpoints/RatingsProvidersEndpoints.cs b/backend/app/Solicitors.Api/Endpoints/RatingsProvidersEndpoints.cs new file mode 100644 index 0000000..2575281 --- /dev/null +++ b/backend/app/Solicitors.Api/Endpoints/RatingsProvidersEndpoints.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; +using Solicitors.Core; + +namespace Solicitors.Api.Endpoints; + +public static class RatingsProvidersEndpoints +{ + public static void MapRatingsProviders(this IEndpointRouteBuilder app) + { + app.MapGet( + "/ratingsProviders", + ( + [FromServices] IRatingsProviderService service, + CancellationToken cancellationToken + ) => service.GetRatingsProvidersAsync(cancellationToken)); + } +} \ No newline at end of file diff --git a/backend/app/Solicitors.Api/Endpoints/SolicitorsEndpoints.cs b/backend/app/Solicitors.Api/Endpoints/SolicitorsEndpoints.cs new file mode 100644 index 0000000..df673da --- /dev/null +++ b/backend/app/Solicitors.Api/Endpoints/SolicitorsEndpoints.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Mvc; +using Solicitors.Core; +using Solicitors.Core.Misc; +using Solicitors.Core.Models; +using Solicitors.Core.Ordering; + +namespace Solicitors.Api.Endpoints; + +public static class SolicitorsEndpoints +{ + public static void MapSolicitors(this IEndpointRouteBuilder app) + { + app.MapGet( + "/solicitors", + ( + [FromServices] IReadOnlySolicitorService solicitorService, + [FromServices] ISolicitorComparerFactory solicitorComparerFactory, + CancellationToken cancellationToken, + [FromQuery] uint pageNumber = 1, + [FromQuery] uint pageSize = 20, + [FromQuery] string? nameFilter = null, + [FromQuery] decimal minRating = 0, + [FromQuery] string[]? cities = null, + [FromQuery] string ratingsProvider = "Solicitors.com", + [FromQuery] OrderingType? orderingType = null) => + { + IFilter? filter = null; + + if (!string.IsNullOrEmpty(nameFilter)) + filter = new WrapperFilter(filter, solicitor => solicitor.Name.Contains(nameFilter)); + + if (cities is not null && cities.Length > 0) + filter = new WrapperFilter(filter, solicitor => cities.Any(city => solicitor.Cities.Any(solCity => solCity.Name == city))); + + if (minRating > 0) + filter = new WrapperFilter( + filter, + solicitor => solicitor.Ratings.Any(rating => rating.Provider == ratingsProvider && rating.Value / rating.Maximum >= minRating / 5.0m)); + + var ordering = solicitorComparerFactory.GetComparer(orderingType, ratingsProvider); + + return solicitorService.GetSolicitorSummariesAsync( + new Pagination(pageNumber, pageSize), + ratingsProvider, + filter, + ordering, + cancellationToken: cancellationToken); + } + ); + + app.MapGet( + "/solicitors/{id:guid}", + ( + [FromServices] IReadOnlySolicitorService solicitorService, + [FromRoute] Guid id, + CancellationToken cancellationToken + ) => solicitorService.GetSolicitorInfoByIdAsync(id, cancellationToken) + ); + } +} \ No newline at end of file diff --git a/backend/app/Solicitors.Api/Program.cs b/backend/app/Solicitors.Api/Program.cs new file mode 100644 index 0000000..7b19b30 --- /dev/null +++ b/backend/app/Solicitors.Api/Program.cs @@ -0,0 +1,41 @@ +using Solicitors.Api.Endpoints; +using Solicitors.CacheBuild; +using Solicitors.Core; +using Solicitors.Data; +using Solicitors.HtmlParsing; + +var builder = WebApplication.CreateBuilder(args); + +builder.Configuration.AddJsonFile("/data/apiSettings.json", optional: true); + +builder.Services.AddOpenApi(); +builder.Services.AddCore(); +builder.Services.AddData(builder.Configuration); +builder.Services.AddCacheBuild(); +builder.Services.Configure(builder.Configuration.GetSection("Imports")); +builder.Services.AddHtmlParsing(); +builder.Services.AddHttpClient(); +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin()); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); +app.MapSolicitors(); +app.MapCities(); +app.MapRatingsProviders(); + +using (var scope = app.Services.CreateScope()) +{ + await scope.UseCacheBuildAsync(CancellationToken.None); +} + +app.Run(); \ No newline at end of file diff --git a/backend/SolicitorsApi/Properties/launchSettings.json b/backend/app/Solicitors.Api/Properties/launchSettings.json similarity index 77% rename from backend/SolicitorsApi/Properties/launchSettings.json rename to backend/app/Solicitors.Api/Properties/launchSettings.json index 3abbdbf..9ac83b5 100644 --- a/backend/SolicitorsApi/Properties/launchSettings.json +++ b/backend/app/Solicitors.Api/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5084", + "applicationUrl": "http://localhost:5254", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:7128;http://localhost:5084", + "applicationUrl": "https://localhost:7226;http://localhost:5254", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/backend/SolicitorsApi/SolicitorsApi.csproj b/backend/app/Solicitors.Api/Solicitors.Api.csproj similarity index 61% rename from backend/SolicitorsApi/SolicitorsApi.csproj rename to backend/app/Solicitors.Api/Solicitors.Api.csproj index 28d473a..4765b66 100644 --- a/backend/SolicitorsApi/SolicitorsApi.csproj +++ b/backend/app/Solicitors.Api/Solicitors.Api.csproj @@ -15,10 +15,8 @@ - - - - + + diff --git a/backend/app/Solicitors.Api/Solicitors.Api.http b/backend/app/Solicitors.Api/Solicitors.Api.http new file mode 100644 index 0000000..a8e024f --- /dev/null +++ b/backend/app/Solicitors.Api/Solicitors.Api.http @@ -0,0 +1,6 @@ +@Solicitors.Api_HostAddress = http://localhost:5254 + +GET {{Solicitors.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/backend/SolicitorsApi/appsettings.json b/backend/app/Solicitors.Api/appsettings.json similarity index 92% rename from backend/SolicitorsApi/appsettings.json rename to backend/app/Solicitors.Api/appsettings.json index 4d56694..149cb09 100644 --- a/backend/SolicitorsApi/appsettings.json +++ b/backend/app/Solicitors.Api/appsettings.json @@ -6,4 +6,4 @@ } }, "AllowedHosts": "*" -} +} \ No newline at end of file diff --git a/backend/libs/Solicitors.CacheBuild/DIExtensions.cs b/backend/libs/Solicitors.CacheBuild/DIExtensions.cs index 4b8ab16..37f832c 100644 --- a/backend/libs/Solicitors.CacheBuild/DIExtensions.cs +++ b/backend/libs/Solicitors.CacheBuild/DIExtensions.cs @@ -1,5 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Quartz; namespace Solicitors.CacheBuild; @@ -10,6 +12,43 @@ public static class DIExtensions { return services .AddScoped() - .AddScoped(); + .AddScoped() + .AddQuartz() + .AddQuartzHostedService(opt => opt.WaitForJobsToComplete = true); + } + + public static async Task UseCacheBuildAsync(this IServiceScope scope, CancellationToken cancellationToken) + { + var factory = scope.ServiceProvider.GetRequiredService(); + var options = scope.ServiceProvider.GetRequiredService>().Value; + var scheduler = await factory.GetScheduler(cancellationToken); + + var importJob = JobBuilder.Create() + .WithIdentity("ImportJob", "ImportGroup") + .Build(); + + var importTrigger = TriggerBuilder.Create() + .WithIdentity("ImportRunner", "ImportGroup") + .StartNow() + .WithSimpleSchedule(builder => builder + .WithIntervalInMinutes(options.ImportMinutes) + .RepeatForever()) + .Build(); + + await scheduler.ScheduleJob(importJob, importTrigger, cancellationToken); + + var staleJob = JobBuilder.Create() + .WithIdentity("StaleDataRemover", "RemoveGroup") + .Build(); + + var staleTrigger = TriggerBuilder.Create() + .WithIdentity("StaleDataTrigger", "RemoveGroup") + .StartNow() + .WithSimpleSchedule(builder => builder + .WithIntervalInMinutes(options.RemoveMinutes) + .RepeatForever()) + .Build(); + + await scheduler.ScheduleJob(staleJob, staleTrigger, cancellationToken); } } \ No newline at end of file diff --git a/backend/libs/Solicitors.CacheBuild/ImportConfiguration.cs b/backend/libs/Solicitors.CacheBuild/ImportConfiguration.cs new file mode 100644 index 0000000..70a8ef6 --- /dev/null +++ b/backend/libs/Solicitors.CacheBuild/ImportConfiguration.cs @@ -0,0 +1,8 @@ +namespace Solicitors.CacheBuild; + +public class ImportConfiguration +{ + public int StaleMinutes { get; set; } + public int RemoveMinutes { get; set; } + public int ImportMinutes { get; set; } +} \ No newline at end of file diff --git a/backend/libs/Solicitors.CacheBuild/ImportRunner.cs b/backend/libs/Solicitors.CacheBuild/ImportRunner.cs new file mode 100644 index 0000000..72cefb7 --- /dev/null +++ b/backend/libs/Solicitors.CacheBuild/ImportRunner.cs @@ -0,0 +1,15 @@ +using Quartz; +using Solicitors.Core.Data; + +namespace Solicitors.CacheBuild; + +internal class ImportRunner( + ISolicitorImporter importer, + ISolicitorRepository repository) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + await repository.EnsureCreatedAsync(context.CancellationToken); + await importer.RunFullImport(context.CancellationToken); + } +} \ No newline at end of file diff --git a/backend/libs/Solicitors.CacheBuild/SolicitorImporter.cs b/backend/libs/Solicitors.CacheBuild/SolicitorImporter.cs index 348be45..220c074 100644 --- a/backend/libs/Solicitors.CacheBuild/SolicitorImporter.cs +++ b/backend/libs/Solicitors.CacheBuild/SolicitorImporter.cs @@ -3,27 +3,18 @@ using Solicitors.Core.Models.Imports; namespace Solicitors.CacheBuild; -internal class SolicitorImporter : ISolicitorImporter +internal class SolicitorImporter( + ISolicitorParser parser, + ISolicitorRepository repository) : ISolicitorImporter { - private readonly ISolicitorParser _parser; - private readonly ISolicitorRepository _repository; - - public SolicitorImporter( - ISolicitorParser parser, - ISolicitorRepository repository) - { - _parser = parser; - _repository = repository; - } - public async Task RunFullImport(CancellationToken cancellationToken) { - foreach (var solicitor in await _parser.GetSolicitorsAsync(cancellationToken)) + foreach (var solicitor in await parser.GetSolicitorsAsync(cancellationToken)) { await RunImport(solicitor, cancellationToken); } } private Task RunImport(SolicitorData solicitor, CancellationToken cancellationToken) - => _repository.AddOrUpdateSolicitorAsync(solicitor, cancellationToken); + => repository.AddOrUpdateSolicitorAsync(solicitor, cancellationToken); } \ No newline at end of file diff --git a/backend/libs/Solicitors.CacheBuild/Solicitors.CacheBuild.csproj b/backend/libs/Solicitors.CacheBuild/Solicitors.CacheBuild.csproj index 66de5d2..7e73086 100644 --- a/backend/libs/Solicitors.CacheBuild/Solicitors.CacheBuild.csproj +++ b/backend/libs/Solicitors.CacheBuild/Solicitors.CacheBuild.csproj @@ -13,6 +13,15 @@ + + + + + + + + ..\..\..\..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\10.0.7\Microsoft.Extensions.Hosting.Abstractions.dll + diff --git a/backend/libs/Solicitors.CacheBuild/SolicitorsDotCom/SolicitorParser.cs b/backend/libs/Solicitors.CacheBuild/SolicitorsDotCom/SolicitorParser.cs index 76b56b1..b2a6460 100644 --- a/backend/libs/Solicitors.CacheBuild/SolicitorsDotCom/SolicitorParser.cs +++ b/backend/libs/Solicitors.CacheBuild/SolicitorsDotCom/SolicitorParser.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using System.Net.Http.Headers; using Solicitors.Core.Misc; using Solicitors.Core.Models.Imports; using Solicitors.HtmlParsing; @@ -154,7 +153,7 @@ internal class SolicitorParser : ISolicitorParser ParseSidebar(sidebarChildren, out phone, out email, out website, out ratings); } - return new SolicitorData() + return new SolicitorData { Name = builder.Name, UrlPath = builder.Path, @@ -168,14 +167,14 @@ internal class SolicitorParser : ISolicitorParser }; } - Location[] ParseOffices(IEnumerable officeNodes) + private Location[] ParseOffices(IEnumerable officeNodes) => officeNodes .Select(ParseOffice) .Where(x => x is not null) .Cast() .ToArray(); - Location? ParseOffice(IHtmlNode officeNode) + private Location? ParseOffice(IHtmlNode officeNode) { if (!officeNode.TryGetChildren(out var officeChildren) || !officeNode.TryGetByTagName("address", out var addressNode) @@ -183,7 +182,7 @@ internal class SolicitorParser : ISolicitorParser return null; Location? location = null; - string? address = ""; + var address = ""; foreach (var line in addressLines) { if (line.TryGetText(out var lineText)) @@ -214,7 +213,7 @@ internal class SolicitorParser : ISolicitorParser return location; } - void ParseSidebar( + private void ParseSidebar( IEnumerable sidebarChildren, out string? phone, out string? email, @@ -253,7 +252,7 @@ internal class SolicitorParser : ISolicitorParser foreach (var child in childrenArray.Where(x => x.TryGetByClass("rev-box", out _, true))) { if (child.TryGetAttributeValue("title", out var ratingString) - && TryParseRatingString(ratingString, out decimal rating, out decimal maxRating) + && TryParseRatingString(ratingString, out var rating, out var maxRating) && child.TryGetByTagNameAndClass("img", "rev-logo", out var imgNode) && imgNode.TryGetAttributeValue("src", out var imgSrc) && TryParseRatingSrc(imgSrc, out var ratingProvider)) @@ -290,15 +289,15 @@ internal class SolicitorParser : ISolicitorParser return decimal.TryParse(parts[0], out rating) && decimal.TryParse(parts[1], out maxRating); } - bool TryParseRatingSrc(string imgSrc, out string provider) + private bool TryParseRatingSrc(string imgSrc, out string provider) { provider = ""; imgSrc = imgSrc.Trim('"'); - var prefix = "/images/logo-"; + const string prefix = "/images/logo-"; if (!imgSrc.StartsWith(prefix)) return false; - var providerAndExtension = imgSrc.Substring(prefix.Length); + var providerAndExtension = imgSrc[prefix.Length..]; provider = providerAndExtension.Split('.').First(); return true; } @@ -306,11 +305,11 @@ internal class SolicitorParser : ISolicitorParser bool TryParseOwnRatingString(string ratingString, out decimal rating) { rating = 0; - var prefix = "Average review score : "; + const string prefix = "Average review score : "; if (!ratingString.StartsWith(prefix)) return false; - var ratingPart = ratingString.Substring(prefix.Length); + var ratingPart = ratingString[prefix.Length..]; return decimal.TryParse(ratingPart, out rating); } } \ No newline at end of file diff --git a/backend/libs/Solicitors.CacheBuild/StaleDataRemover.cs b/backend/libs/Solicitors.CacheBuild/StaleDataRemover.cs new file mode 100644 index 0000000..66684af --- /dev/null +++ b/backend/libs/Solicitors.CacheBuild/StaleDataRemover.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Options; +using Quartz; +using Solicitors.Core.Data; + +namespace Solicitors.CacheBuild; + +internal class StaleDataRemover( + IOptions config, + ISolicitorRepository repository) : IJob +{ + private readonly ImportConfiguration _config = config.Value; + + public Task Execute(IJobExecutionContext context) + { + return repository.RemoveStaleEntriesAsync( + TimeSpan.FromMinutes(_config.StaleMinutes), + context.CancellationToken); + } +} \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/DIExtensions.cs b/backend/libs/Solicitors.Core/DIExtensions.cs index b7feb4f..8111f79 100644 --- a/backend/libs/Solicitors.Core/DIExtensions.cs +++ b/backend/libs/Solicitors.Core/DIExtensions.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; +using Solicitors.Core.Ordering; namespace Solicitors.Core; @@ -12,6 +13,7 @@ public static class DIExtensions .AddScoped() .AddScoped(sp => sp.GetRequiredService()) .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); } } \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/Data/IReadOnlySolicitorRepository.cs b/backend/libs/Solicitors.Core/Data/IReadOnlySolicitorRepository.cs index 7314b88..d0d2293 100644 --- a/backend/libs/Solicitors.Core/Data/IReadOnlySolicitorRepository.cs +++ b/backend/libs/Solicitors.Core/Data/IReadOnlySolicitorRepository.cs @@ -10,5 +10,5 @@ public interface IReadOnlySolicitorRepository public interface IReadOnlyCitiesRepository { - IAsyncEnumerable GetCitiesAsync(CancellationToken cancellationToken); + IAsyncEnumerable GetCitiesAsync(); } \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/Data/ISolicitorRepository.cs b/backend/libs/Solicitors.Core/Data/ISolicitorRepository.cs index 72dd972..a2227e2 100644 --- a/backend/libs/Solicitors.Core/Data/ISolicitorRepository.cs +++ b/backend/libs/Solicitors.Core/Data/ISolicitorRepository.cs @@ -4,5 +4,7 @@ namespace Solicitors.Core.Data; public interface ISolicitorRepository : IReadOnlySolicitorRepository { + Task EnsureCreatedAsync(CancellationToken cancellationToken); Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken); + Task RemoveStaleEntriesAsync(TimeSpan staleAge, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/IReadOnlySolicitorService.cs b/backend/libs/Solicitors.Core/IReadOnlySolicitorService.cs index 030270a..e2db2fd 100644 --- a/backend/libs/Solicitors.Core/IReadOnlySolicitorService.cs +++ b/backend/libs/Solicitors.Core/IReadOnlySolicitorService.cs @@ -1,5 +1,6 @@ using Solicitors.Core.Misc; using Solicitors.Core.Models; +using Solicitors.Core.Models.View; namespace Solicitors.Core; diff --git a/backend/libs/Solicitors.Core/ISolicitorService.cs b/backend/libs/Solicitors.Core/ISolicitorService.cs index 1bc9d56..78aa8bb 100644 --- a/backend/libs/Solicitors.Core/ISolicitorService.cs +++ b/backend/libs/Solicitors.Core/ISolicitorService.cs @@ -1,6 +1,3 @@ namespace Solicitors.Core; -public interface ISolicitorService : IReadOnlySolicitorService -{ - -} \ No newline at end of file +public interface ISolicitorService : IReadOnlySolicitorService; \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/Misc/IFilter.cs b/backend/libs/Solicitors.Core/Misc/IFilter.cs index 8e82d43..c12c07b 100644 --- a/backend/libs/Solicitors.Core/Misc/IFilter.cs +++ b/backend/libs/Solicitors.Core/Misc/IFilter.cs @@ -1,29 +1,19 @@ namespace Solicitors.Core.Misc; -public interface IFilter +public interface IFilter { bool Filter(T item); } -public class WrapperFilter : IFilter +public class WrapperFilter( + IFilter? inner, + Func filter) : IFilter { - private readonly Func _filter; - private readonly IFilter? _inner; - - public WrapperFilter( - IFilter? inner, - Func filter) - { - _inner = inner; - _filter = filter; - } - - public bool Filter(T item) { - if (_inner is not null) - return _inner.Filter(item) && _filter(item); + if (inner is not null) + return inner.Filter(item) && filter(item); - return _filter(item); + return filter(item); } } \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/Models/City.cs b/backend/libs/Solicitors.Core/Models/City.cs index f183649..5ba38e1 100644 --- a/backend/libs/Solicitors.Core/Models/City.cs +++ b/backend/libs/Solicitors.Core/Models/City.cs @@ -4,6 +4,7 @@ public record City { public Guid CityId { get; set; } public required string Name { get; set; } + public DateTime LastModified { get; set; } - public List Solicitors { get; } = new(); + public List Solicitors { get; } = []; } \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/Models/Imports/Location.cs b/backend/libs/Solicitors.Core/Models/Imports/Location.cs index 14c6fb6..e70a309 100644 --- a/backend/libs/Solicitors.Core/Models/Imports/Location.cs +++ b/backend/libs/Solicitors.Core/Models/Imports/Location.cs @@ -1,8 +1,8 @@ namespace Solicitors.Core.Models.Imports; -public record Location(string address, string phone, Rating[] locationRatings) +public record Location(string Address, string Phone, Rating[] LocationRatings) { - public string Address { get; } = address; - public string Phone { get; } = phone; - public Rating[] LocationRatings { get; } = locationRatings; + public string Address { get; } = Address; + public string Phone { get; } = Phone; + public Rating[] LocationRatings { get; } = LocationRatings; } \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/Models/Imports/Rating.cs b/backend/libs/Solicitors.Core/Models/Imports/Rating.cs index 7893907..da987e4 100644 --- a/backend/libs/Solicitors.Core/Models/Imports/Rating.cs +++ b/backend/libs/Solicitors.Core/Models/Imports/Rating.cs @@ -1,9 +1,9 @@ namespace Solicitors.Core.Models.Imports; -public record Rating(decimal value, decimal maxValue, string provider, string imgSrc) +public record Rating(decimal Value, decimal MaxValue, string RatingProvider, string RatingProviderImgSrc) { - public decimal Value { get; } = value; - public decimal MaxValue { get; } = maxValue; - public string RatingProvider { get; } = provider; - public string RatingProviderImgSrc { get; } = imgSrc; + public decimal Value { get; } = Value; + public decimal MaxValue { get; } = MaxValue; + public string RatingProvider { get; } = RatingProvider; + public string RatingProviderImgSrc { get; } = RatingProviderImgSrc; } \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/Models/Solicitor.cs b/backend/libs/Solicitors.Core/Models/Solicitor.cs index 0172467..01d932a 100644 --- a/backend/libs/Solicitors.Core/Models/Solicitor.cs +++ b/backend/libs/Solicitors.Core/Models/Solicitor.cs @@ -11,8 +11,9 @@ public record Solicitor public string? Email { get; set; } public string? Website { get; set; } public string? ShortDescription { get; set; } + public DateTime LastModified { get; set; } - public List Cities { get; } = new(); - public List Ratings { get; } = new(); - public List Locations { get; } = new(); + public List Cities { get; } = []; + public List Ratings { get; } = []; + public List Locations { get; } = []; } \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/Models/SolicitorInfo.cs b/backend/libs/Solicitors.Core/Models/SolicitorInfo.cs index ac7c5a2..3691859 100644 --- a/backend/libs/Solicitors.Core/Models/SolicitorInfo.cs +++ b/backend/libs/Solicitors.Core/Models/SolicitorInfo.cs @@ -1,3 +1,5 @@ +using Solicitors.Core.Models.View; + namespace Solicitors.Core.Models; public record SolicitorInfo @@ -25,7 +27,7 @@ public record SolicitorInfo } public string Name { get; set; } - public string ShortDescription { get; set; } + public string? ShortDescription { get; set; } public Guid Id { get; set; } public string? Phone { get; set; } diff --git a/backend/libs/Solicitors.Core/Models/SolicitorLocation.cs b/backend/libs/Solicitors.Core/Models/SolicitorLocation.cs index 84111b3..a9e787e 100644 --- a/backend/libs/Solicitors.Core/Models/SolicitorLocation.cs +++ b/backend/libs/Solicitors.Core/Models/SolicitorLocation.cs @@ -6,8 +6,9 @@ public record SolicitorLocation public required string Address { get; set; } public required string Phone { get; set; } + public DateTime LastModified { get; set; } - public List LocationRatings { get; } = new(); + public List LocationRatings { get; } = []; public Guid SolicitorId { get; set; } public required Solicitor Solicitor { get; set; } diff --git a/backend/libs/Solicitors.Core/Models/SolicitorLocationRating.cs b/backend/libs/Solicitors.Core/Models/SolicitorLocationRating.cs index f578ac8..671035a 100644 --- a/backend/libs/Solicitors.Core/Models/SolicitorLocationRating.cs +++ b/backend/libs/Solicitors.Core/Models/SolicitorLocationRating.cs @@ -7,6 +7,7 @@ public record SolicitorLocationRating : IRating public required decimal Value { get; set; } public required decimal Maximum { get; set; } public required string Provider { get; set; } + public DateTime LastModified { get; set; } public Guid SolicitorLocationId { get; set; } public required SolicitorLocation SolicitorLocation { get; set; } diff --git a/backend/libs/Solicitors.Core/Models/SolicitorRating.cs b/backend/libs/Solicitors.Core/Models/SolicitorRating.cs index e8edae2..5c9a702 100644 --- a/backend/libs/Solicitors.Core/Models/SolicitorRating.cs +++ b/backend/libs/Solicitors.Core/Models/SolicitorRating.cs @@ -7,6 +7,7 @@ public record SolicitorRating : IRating public required decimal Value { get; set; } public required decimal Maximum { get; set; } public required string Provider { get; set; } + public DateTime LastModified { get; set; } public Guid SolicitorId { get; set; } public required Solicitor Solicitor { get; set; } diff --git a/backend/libs/Solicitors.Core/Models/LocationInfo.cs b/backend/libs/Solicitors.Core/Models/View/LocationInfo.cs similarity index 91% rename from backend/libs/Solicitors.Core/Models/LocationInfo.cs rename to backend/libs/Solicitors.Core/Models/View/LocationInfo.cs index 63ef4d6..41e3d2a 100644 --- a/backend/libs/Solicitors.Core/Models/LocationInfo.cs +++ b/backend/libs/Solicitors.Core/Models/View/LocationInfo.cs @@ -1,4 +1,4 @@ -namespace Solicitors.Core.Models; +namespace Solicitors.Core.Models.View; public record LocationInfo { diff --git a/backend/libs/Solicitors.Core/Models/RatingInfo.cs b/backend/libs/Solicitors.Core/Models/View/RatingInfo.cs similarity index 88% rename from backend/libs/Solicitors.Core/Models/RatingInfo.cs rename to backend/libs/Solicitors.Core/Models/View/RatingInfo.cs index 9564194..aed300f 100644 --- a/backend/libs/Solicitors.Core/Models/RatingInfo.cs +++ b/backend/libs/Solicitors.Core/Models/View/RatingInfo.cs @@ -1,4 +1,4 @@ -namespace Solicitors.Core.Models; +namespace Solicitors.Core.Models.View; public record RatingInfo { diff --git a/backend/libs/Solicitors.Core/Models/SolicitorSummary.cs b/backend/libs/Solicitors.Core/Models/View/SolicitorSummary.cs similarity index 93% rename from backend/libs/Solicitors.Core/Models/SolicitorSummary.cs rename to backend/libs/Solicitors.Core/Models/View/SolicitorSummary.cs index da5f195..d2ce52d 100644 --- a/backend/libs/Solicitors.Core/Models/SolicitorSummary.cs +++ b/backend/libs/Solicitors.Core/Models/View/SolicitorSummary.cs @@ -1,4 +1,4 @@ -namespace Solicitors.Core.Models; +namespace Solicitors.Core.Models.View; public record SolicitorSummary { diff --git a/backend/libs/Solicitors.Core/Misc/DefaultSolicitorDataOrdering.cs b/backend/libs/Solicitors.Core/Ordering/DefaultSolicitorDataOrdering.cs similarity index 93% rename from backend/libs/Solicitors.Core/Misc/DefaultSolicitorDataOrdering.cs rename to backend/libs/Solicitors.Core/Ordering/DefaultSolicitorDataOrdering.cs index 7621939..59f4b28 100644 --- a/backend/libs/Solicitors.Core/Misc/DefaultSolicitorDataOrdering.cs +++ b/backend/libs/Solicitors.Core/Ordering/DefaultSolicitorDataOrdering.cs @@ -1,6 +1,6 @@ using Solicitors.Core.Models; -namespace Solicitors.Core.Misc; +namespace Solicitors.Core.Ordering; internal class DefaultSolicitorDataOrdering : IComparer { diff --git a/backend/libs/Solicitors.Core/Ordering/ISolicitorComparerFactory.cs b/backend/libs/Solicitors.Core/Ordering/ISolicitorComparerFactory.cs new file mode 100644 index 0000000..69507b4 --- /dev/null +++ b/backend/libs/Solicitors.Core/Ordering/ISolicitorComparerFactory.cs @@ -0,0 +1,10 @@ +using Solicitors.Core.Models; + +namespace Solicitors.Core.Ordering; + +public interface ISolicitorComparerFactory +{ + IComparer GetComparer( + OrderingType? orderingType, + string ratingsProvider); +} \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/Ordering/NameComparer.cs b/backend/libs/Solicitors.Core/Ordering/NameComparer.cs new file mode 100644 index 0000000..ddd9487 --- /dev/null +++ b/backend/libs/Solicitors.Core/Ordering/NameComparer.cs @@ -0,0 +1,26 @@ +using Solicitors.Core.Models; + +namespace Solicitors.Core.Ordering; + +internal class NameComparer(bool ascending) : IComparer +{ + public int Compare(Solicitor? x, Solicitor? y) + { + int compare; + if (ReferenceEquals(x, y)) + compare = 0; + else if (y is null) + compare = 1; + else if (x is null) + compare = -1; + else + { + compare = string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); + + if (!ascending) + compare *= -1; + } + + return compare; + } +} \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/Ordering/OrderingType.cs b/backend/libs/Solicitors.Core/Ordering/OrderingType.cs new file mode 100644 index 0000000..1a3d38f --- /dev/null +++ b/backend/libs/Solicitors.Core/Ordering/OrderingType.cs @@ -0,0 +1,9 @@ +namespace Solicitors.Core.Ordering; + +public enum OrderingType +{ + RatingDescending, + RatingAscending, + AlphabetAscending, + AlphabetDescending +} \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/Ordering/RatingComparer.cs b/backend/libs/Solicitors.Core/Ordering/RatingComparer.cs new file mode 100644 index 0000000..7714bd7 --- /dev/null +++ b/backend/libs/Solicitors.Core/Ordering/RatingComparer.cs @@ -0,0 +1,32 @@ +using Solicitors.Core.Models; + +namespace Solicitors.Core.Ordering; + +internal class RatingComparer(string ratingsProvider, bool ascending) : IComparer +{ + public int Compare(Solicitor? x, Solicitor? y) + { + var xRating = x?.Ratings.FirstOrDefault(r => r.Provider == ratingsProvider); + var yRating = y?.Ratings.FirstOrDefault(r => r.Provider == ratingsProvider); + + decimal compare; + + if (xRating is null) + compare = yRating is null ? 0 : 1; + else if (yRating is null) + compare = -1; + else + { + compare = (xRating.Value / xRating.Maximum) - (yRating.Value / yRating.Maximum); + + if (!ascending) + compare *= -1; + } + + if (compare >= 0) + return (int)Math.Ceiling(compare); + else + return (int)Math.Floor(compare); + + } +} \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/Ordering/SolicitorComparerFactory.cs b/backend/libs/Solicitors.Core/Ordering/SolicitorComparerFactory.cs new file mode 100644 index 0000000..784ec2f --- /dev/null +++ b/backend/libs/Solicitors.Core/Ordering/SolicitorComparerFactory.cs @@ -0,0 +1,20 @@ +using Solicitors.Core.Models; + +namespace Solicitors.Core.Ordering; + +internal class SolicitorComparerFactory : ISolicitorComparerFactory +{ + public IComparer GetComparer( + OrderingType? orderingType, + string ratingsProvider) + { + return orderingType switch + { + OrderingType.RatingAscending => new RatingComparer(ratingsProvider, true), + OrderingType.RatingDescending => new RatingComparer(ratingsProvider, false), + OrderingType.AlphabetAscending => new NameComparer(true), + OrderingType.AlphabetDescending => new NameComparer(false), + _ => DefaultSolicitorDataOrdering.Instance + }; + } +} \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/RatingsProviderService.cs b/backend/libs/Solicitors.Core/RatingsProviderService.cs index e4f2ed8..7fe71a5 100644 --- a/backend/libs/Solicitors.Core/RatingsProviderService.cs +++ b/backend/libs/Solicitors.Core/RatingsProviderService.cs @@ -2,17 +2,10 @@ using Solicitors.Core.Data; namespace Solicitors.Core; -internal class RatingsProviderService : IRatingsProviderService +internal class RatingsProviderService(IRatingsProviderRepository repo) : IRatingsProviderService { - private readonly IRatingsProviderRepository _repo; - - public RatingsProviderService(IRatingsProviderRepository repo) - { - _repo = repo; - } - public async Task GetRatingsProvidersAsync(CancellationToken cancellationToken) { - return await _repo.GetAllRatingsProvidersAsync().ToArrayAsync(cancellationToken); + return await repo.GetAllRatingsProvidersAsync().ToArrayAsync(cancellationToken); } } \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/ReadOnlyCitiesService.cs b/backend/libs/Solicitors.Core/ReadOnlyCitiesService.cs index 28b8371..19f7e61 100644 --- a/backend/libs/Solicitors.Core/ReadOnlyCitiesService.cs +++ b/backend/libs/Solicitors.Core/ReadOnlyCitiesService.cs @@ -2,18 +2,11 @@ using Solicitors.Core.Data; namespace Solicitors.Core; -internal class ReadOnlyCitiesService : IReadOnlyCitiesService +internal class ReadOnlyCitiesService(IReadOnlyCitiesRepository repo) : IReadOnlyCitiesService { - private readonly IReadOnlyCitiesRepository _repo; - - public ReadOnlyCitiesService(IReadOnlyCitiesRepository repo) - { - _repo = repo; - } - public async Task GetAllCitiesAsync(CancellationToken cancellationToken) { - var cities = _repo.GetCitiesAsync(cancellationToken); + var cities = repo.GetCitiesAsync(); return await cities.ToArrayAsync(cancellationToken); } } \ No newline at end of file diff --git a/backend/libs/Solicitors.Core/SolicitorService.cs b/backend/libs/Solicitors.Core/SolicitorService.cs index ad329a0..114ea43 100644 --- a/backend/libs/Solicitors.Core/SolicitorService.cs +++ b/backend/libs/Solicitors.Core/SolicitorService.cs @@ -1,18 +1,13 @@ using Solicitors.Core.Data; using Solicitors.Core.Misc; using Solicitors.Core.Models; +using Solicitors.Core.Models.View; +using Solicitors.Core.Ordering; namespace Solicitors.Core; -internal class SolicitorService : ISolicitorService +internal class SolicitorService(IReadOnlySolicitorRepository repo) : ISolicitorService { - private readonly IReadOnlySolicitorRepository _repo; - - public SolicitorService(IReadOnlySolicitorRepository repo) - { - _repo = repo; - } - public async Task> GetSolicitorSummariesAsync( Pagination pagination, string ratingsProvider, @@ -20,14 +15,13 @@ internal class SolicitorService : ISolicitorService IComparer? ordering = null, CancellationToken cancellationToken = default) { - var allSolicitors = _repo.GetAllSolicitorsAsync(); + var allSolicitors = repo.GetAllSolicitorsAsync(); if (filter is not null) allSolicitors = allSolicitors.Where(filter.Filter); var matchingSolicitors = await allSolicitors.ToArrayAsync(cancellationToken); - if (ordering is null) - ordering = DefaultSolicitorDataOrdering.Instance; + ordering ??= DefaultSolicitorDataOrdering.Instance; var results = matchingSolicitors .OrderBy(x => x, ordering) @@ -41,7 +35,7 @@ internal class SolicitorService : ISolicitorService public async Task GetSolicitorInfoByIdAsync(Guid id, CancellationToken cancellationToken = default) { - var data = await _repo.GetSolicitorByIdAsync(id, cancellationToken); + var data = await repo.GetSolicitorByIdAsync(id, cancellationToken); if (data is null) return null; diff --git a/backend/libs/Solicitors.Data/DIExtensions.cs b/backend/libs/Solicitors.Data/DIExtensions.cs index a8cda75..0c0087a 100644 --- a/backend/libs/Solicitors.Data/DIExtensions.cs +++ b/backend/libs/Solicitors.Data/DIExtensions.cs @@ -1,7 +1,6 @@ using System.Diagnostics.CodeAnalysis; -using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Solicitors.Core; using Solicitors.Core.Data; using Solicitors.Data.RepositorySetup; using Solicitors.Data.RepositorySetup.InMemory; @@ -12,7 +11,7 @@ namespace Solicitors.Data; [SuppressMessage("ReSharper", "InconsistentNaming")] public static class DIExtensions { - public static IServiceCollection AddData(this IServiceCollection services, IRepoSetupOptions options) + public static IServiceCollection AddData(this IServiceCollection services, IConfiguration configuration) { return services .AddScoped() @@ -20,17 +19,30 @@ public static class DIExtensions .AddScoped(sp => sp.GetRequiredService()) .AddScoped(sp => sp.GetRequiredService()) .AddScoped(sp => sp.GetRequiredService()) - .AddSingleton(GetSetupService(options)); + .AddSingleton(GetSetupService(configuration)); } - private static IRepoSetupService GetSetupService(IRepoSetupOptions options) + private static IRepoSetupService GetSetupService(IConfiguration configuration) { - if (options is InMemoryDbSetupOptions inMemOptions) - return new InMemorySetupService(inMemOptions); + var options = ReadConfigForOptions(configuration); - if (options is SqliteDbSetupOptions sqliteOptions) - return new SqliteSetupService(sqliteOptions); + return options switch + { + InMemoryDbSetupOptions inMemOptions => new InMemorySetupService(inMemOptions), + SqliteDbSetupOptions sqliteOptions => new SqliteSetupService(sqliteOptions), + _ => throw new ArgumentException("Options type not supported", nameof(configuration)) + }; + } - throw new ArgumentException("Options type not supported", nameof(options)); + private static IRepoSetupOptions? ReadConfigForOptions(IConfiguration configuration) + { + var dbConfig = configuration.GetSection("Database"); + var dbType = dbConfig["Type"]?.ToLower(); + return dbType switch + { + "inmemory" => dbConfig.Get(), + "sqlite" => dbConfig.Get(), + _ => null + }; } } \ No newline at end of file diff --git a/backend/libs/Solicitors.Data/Migrations/20260622195820_InitialCreate.Designer.cs b/backend/libs/Solicitors.Data/Migrations/20260622195820_InitialCreate.Designer.cs deleted file mode 100644 index 0b8622e..0000000 --- a/backend/libs/Solicitors.Data/Migrations/20260622195820_InitialCreate.Designer.cs +++ /dev/null @@ -1,222 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Solicitors.Data; - -#nullable disable - -namespace Solicitors.Data.Migrations -{ - [DbContext(typeof(SolicitorsRepository))] - [Migration("20260622195820_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); - - modelBuilder.Entity("CitySolicitor", b => - { - b.Property("CitiesCityId") - .HasColumnType("TEXT"); - - b.Property("SolicitorsSolicitorId") - .HasColumnType("TEXT"); - - b.HasKey("CitiesCityId", "SolicitorsSolicitorId"); - - b.HasIndex("SolicitorsSolicitorId"); - - b.ToTable("CitySolicitor"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.City", b => - { - b.Property("CityId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("CityId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b => - { - b.Property("SolicitorId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Email") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Phone") - .HasColumnType("TEXT"); - - b.Property("RelativeUrl") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("ShortDescription") - .HasColumnType("TEXT"); - - b.Property("Website") - .HasColumnType("TEXT"); - - b.HasKey("SolicitorId"); - - b.ToTable("Solicitors"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b => - { - b.Property("SolicitorLocationId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Address") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Phone") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("SolicitorId") - .HasColumnType("TEXT"); - - b.HasKey("SolicitorLocationId"); - - b.HasIndex("SolicitorId"); - - b.ToTable("SolicitorLocations"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b => - { - b.Property("SolicitorLocationRatingId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Maximum") - .HasColumnType("TEXT"); - - b.Property("Provider") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("SolicitorLocationId") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("SolicitorLocationRatingId"); - - b.HasIndex("SolicitorLocationId"); - - b.ToTable("SolicitorLocationRatings"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b => - { - b.Property("SolicitorRatingId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Maximum") - .HasColumnType("TEXT"); - - b.Property("Provider") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("SolicitorId") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("SolicitorRatingId"); - - b.HasIndex("SolicitorId"); - - b.ToTable("SolicitorRatings"); - }); - - modelBuilder.Entity("CitySolicitor", b => - { - b.HasOne("Solicitors.Core.Models.City", null) - .WithMany() - .HasForeignKey("CitiesCityId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Solicitors.Core.Models.Solicitor", null) - .WithMany() - .HasForeignKey("SolicitorsSolicitorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b => - { - b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor") - .WithMany("Locations") - .HasForeignKey("SolicitorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Solicitor"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b => - { - b.HasOne("Solicitors.Core.Models.SolicitorLocation", "SolicitorLocation") - .WithMany("LocationRatings") - .HasForeignKey("SolicitorLocationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("SolicitorLocation"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b => - { - b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor") - .WithMany("Ratings") - .HasForeignKey("SolicitorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Solicitor"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b => - { - b.Navigation("Locations"); - - b.Navigation("Ratings"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b => - { - b.Navigation("LocationRatings"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/libs/Solicitors.Data/Migrations/20260622195820_InitialCreate.cs b/backend/libs/Solicitors.Data/Migrations/20260622195820_InitialCreate.cs deleted file mode 100644 index 708be18..0000000 --- a/backend/libs/Solicitors.Data/Migrations/20260622195820_InitialCreate.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Solicitors.Data.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Cities", - columns: table => new - { - CityId = table.Column(type: "TEXT", nullable: false), - Name = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Cities", x => x.CityId); - }); - - migrationBuilder.CreateTable( - name: "Solicitors", - columns: table => new - { - SolicitorId = table.Column(type: "TEXT", nullable: false), - Name = table.Column(type: "TEXT", nullable: false), - RelativeUrl = table.Column(type: "TEXT", nullable: false), - Phone = table.Column(type: "TEXT", nullable: true), - Email = table.Column(type: "TEXT", nullable: true), - Website = table.Column(type: "TEXT", nullable: true), - ShortDescription = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Solicitors", x => x.SolicitorId); - }); - - migrationBuilder.CreateTable( - name: "CitySolicitor", - columns: table => new - { - CitiesCityId = table.Column(type: "TEXT", nullable: false), - SolicitorsSolicitorId = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_CitySolicitor", x => new { x.CitiesCityId, x.SolicitorsSolicitorId }); - table.ForeignKey( - name: "FK_CitySolicitor_Cities_CitiesCityId", - column: x => x.CitiesCityId, - principalTable: "Cities", - principalColumn: "CityId", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_CitySolicitor_Solicitors_SolicitorsSolicitorId", - column: x => x.SolicitorsSolicitorId, - principalTable: "Solicitors", - principalColumn: "SolicitorId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "SolicitorLocations", - columns: table => new - { - SolicitorLocationId = table.Column(type: "TEXT", nullable: false), - Address = table.Column(type: "TEXT", nullable: false), - Phone = table.Column(type: "TEXT", nullable: false), - SolicitorId = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_SolicitorLocations", x => x.SolicitorLocationId); - table.ForeignKey( - name: "FK_SolicitorLocations_Solicitors_SolicitorId", - column: x => x.SolicitorId, - principalTable: "Solicitors", - principalColumn: "SolicitorId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "SolicitorRatings", - columns: table => new - { - SolicitorRatingId = table.Column(type: "TEXT", nullable: false), - Value = table.Column(type: "TEXT", nullable: false), - Maximum = table.Column(type: "TEXT", nullable: false), - Provider = table.Column(type: "TEXT", nullable: false), - SolicitorId = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_SolicitorRatings", x => x.SolicitorRatingId); - table.ForeignKey( - name: "FK_SolicitorRatings_Solicitors_SolicitorId", - column: x => x.SolicitorId, - principalTable: "Solicitors", - principalColumn: "SolicitorId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "SolicitorLocationRatings", - columns: table => new - { - SolicitorLocationRatingId = table.Column(type: "TEXT", nullable: false), - Value = table.Column(type: "TEXT", nullable: false), - Maximum = table.Column(type: "TEXT", nullable: false), - Provider = table.Column(type: "TEXT", nullable: false), - SolicitorLocationId = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_SolicitorLocationRatings", x => x.SolicitorLocationRatingId); - table.ForeignKey( - name: "FK_SolicitorLocationRatings_SolicitorLocations_SolicitorLocationId", - column: x => x.SolicitorLocationId, - principalTable: "SolicitorLocations", - principalColumn: "SolicitorLocationId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_CitySolicitor_SolicitorsSolicitorId", - table: "CitySolicitor", - column: "SolicitorsSolicitorId"); - - migrationBuilder.CreateIndex( - name: "IX_SolicitorLocationRatings_SolicitorLocationId", - table: "SolicitorLocationRatings", - column: "SolicitorLocationId"); - - migrationBuilder.CreateIndex( - name: "IX_SolicitorLocations_SolicitorId", - table: "SolicitorLocations", - column: "SolicitorId"); - - migrationBuilder.CreateIndex( - name: "IX_SolicitorRatings_SolicitorId", - table: "SolicitorRatings", - column: "SolicitorId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "CitySolicitor"); - - migrationBuilder.DropTable( - name: "SolicitorLocationRatings"); - - migrationBuilder.DropTable( - name: "SolicitorRatings"); - - migrationBuilder.DropTable( - name: "Cities"); - - migrationBuilder.DropTable( - name: "SolicitorLocations"); - - migrationBuilder.DropTable( - name: "Solicitors"); - } - } -} diff --git a/backend/libs/Solicitors.Data/Migrations/SolicitorsRepositoryModelSnapshot.cs b/backend/libs/Solicitors.Data/Migrations/SolicitorsRepositoryModelSnapshot.cs deleted file mode 100644 index 3552058..0000000 --- a/backend/libs/Solicitors.Data/Migrations/SolicitorsRepositoryModelSnapshot.cs +++ /dev/null @@ -1,219 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Solicitors.Data; - -#nullable disable - -namespace Solicitors.Data.Migrations -{ - [DbContext(typeof(SolicitorsRepository))] - partial class SolicitorsRepositoryModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); - - modelBuilder.Entity("CitySolicitor", b => - { - b.Property("CitiesCityId") - .HasColumnType("TEXT"); - - b.Property("SolicitorsSolicitorId") - .HasColumnType("TEXT"); - - b.HasKey("CitiesCityId", "SolicitorsSolicitorId"); - - b.HasIndex("SolicitorsSolicitorId"); - - b.ToTable("CitySolicitor"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.City", b => - { - b.Property("CityId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("CityId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b => - { - b.Property("SolicitorId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Email") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Phone") - .HasColumnType("TEXT"); - - b.Property("RelativeUrl") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("ShortDescription") - .HasColumnType("TEXT"); - - b.Property("Website") - .HasColumnType("TEXT"); - - b.HasKey("SolicitorId"); - - b.ToTable("Solicitors"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b => - { - b.Property("SolicitorLocationId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Address") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Phone") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("SolicitorId") - .HasColumnType("TEXT"); - - b.HasKey("SolicitorLocationId"); - - b.HasIndex("SolicitorId"); - - b.ToTable("SolicitorLocations"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b => - { - b.Property("SolicitorLocationRatingId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Maximum") - .HasColumnType("TEXT"); - - b.Property("Provider") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("SolicitorLocationId") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("SolicitorLocationRatingId"); - - b.HasIndex("SolicitorLocationId"); - - b.ToTable("SolicitorLocationRatings"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b => - { - b.Property("SolicitorRatingId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Maximum") - .HasColumnType("TEXT"); - - b.Property("Provider") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("SolicitorId") - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("SolicitorRatingId"); - - b.HasIndex("SolicitorId"); - - b.ToTable("SolicitorRatings"); - }); - - modelBuilder.Entity("CitySolicitor", b => - { - b.HasOne("Solicitors.Core.Models.City", null) - .WithMany() - .HasForeignKey("CitiesCityId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Solicitors.Core.Models.Solicitor", null) - .WithMany() - .HasForeignKey("SolicitorsSolicitorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b => - { - b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor") - .WithMany("Locations") - .HasForeignKey("SolicitorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Solicitor"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b => - { - b.HasOne("Solicitors.Core.Models.SolicitorLocation", "SolicitorLocation") - .WithMany("LocationRatings") - .HasForeignKey("SolicitorLocationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("SolicitorLocation"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b => - { - b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor") - .WithMany("Ratings") - .HasForeignKey("SolicitorId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Solicitor"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b => - { - b.Navigation("Locations"); - - b.Navigation("Ratings"); - }); - - modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b => - { - b.Navigation("LocationRatings"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/backend/libs/Solicitors.Data/RepositorySetup/IRepoSetupOptions.cs b/backend/libs/Solicitors.Data/RepositorySetup/IRepoSetupOptions.cs index 6a0c460..0077099 100644 --- a/backend/libs/Solicitors.Data/RepositorySetup/IRepoSetupOptions.cs +++ b/backend/libs/Solicitors.Data/RepositorySetup/IRepoSetupOptions.cs @@ -1,6 +1,3 @@ namespace Solicitors.Data.RepositorySetup; -public interface IRepoSetupOptions -{ - -} \ No newline at end of file +public interface IRepoSetupOptions; \ No newline at end of file diff --git a/backend/libs/Solicitors.Data/RepositorySetup/Sqlite/SqliteDbSetupOptions.cs b/backend/libs/Solicitors.Data/RepositorySetup/Sqlite/SqliteDbSetupOptions.cs index 7bc1f54..205410c 100644 --- a/backend/libs/Solicitors.Data/RepositorySetup/Sqlite/SqliteDbSetupOptions.cs +++ b/backend/libs/Solicitors.Data/RepositorySetup/Sqlite/SqliteDbSetupOptions.cs @@ -1,6 +1,6 @@ namespace Solicitors.Data.RepositorySetup.Sqlite; -public class SqliteDbSetupOptions(string dbName) : IRepoSetupOptions +public class SqliteDbSetupOptions(string dbPath) : IRepoSetupOptions { - public string DbName { get; } = dbName; + public string DbPath { get; } = dbPath; } \ No newline at end of file diff --git a/backend/libs/Solicitors.Data/RepositorySetup/Sqlite/SqliteSetupService.cs b/backend/libs/Solicitors.Data/RepositorySetup/Sqlite/SqliteSetupService.cs index 2568541..d2b6a76 100644 --- a/backend/libs/Solicitors.Data/RepositorySetup/Sqlite/SqliteSetupService.cs +++ b/backend/libs/Solicitors.Data/RepositorySetup/Sqlite/SqliteSetupService.cs @@ -6,11 +6,6 @@ internal class SqliteSetupService(SqliteDbSetupOptions config) : IRepoSetupServi { public void OnConfiguring(DbContextOptionsBuilder options) { - var folder = Environment.SpecialFolder.MyDocuments; - var path = Environment.GetFolderPath(folder); - path = Path.Combine(path, "/data"); - var dbPath = System.IO.Path.Join(path, $"{config.DbName}.db"); - var dbString = $"Data Source={dbPath}"; - options.UseSqlite(dbString); + options.UseSqlite($"Data Source={config.DbPath}"); } } \ No newline at end of file diff --git a/backend/libs/Solicitors.Data/Solicitors.Data.csproj b/backend/libs/Solicitors.Data/Solicitors.Data.csproj index 5685344..cec2d80 100644 --- a/backend/libs/Solicitors.Data/Solicitors.Data.csproj +++ b/backend/libs/Solicitors.Data/Solicitors.Data.csproj @@ -14,6 +14,7 @@ + diff --git a/backend/libs/Solicitors.Data/SolicitorsRepository.cs b/backend/libs/Solicitors.Data/SolicitorsRepository.cs index de69605..3fd6ab1 100644 --- a/backend/libs/Solicitors.Data/SolicitorsRepository.cs +++ b/backend/libs/Solicitors.Data/SolicitorsRepository.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using Solicitors.Core; using Solicitors.Core.Data; using Solicitors.Core.Models; using Solicitors.Core.Models.Imports; @@ -7,15 +6,9 @@ using Solicitors.Data.RepositorySetup; namespace Solicitors.Data; -internal class SolicitorsRepository : DbContext, ISolicitorRepository, IReadOnlyCitiesRepository, IRatingsProviderRepository +internal class SolicitorsRepository(IRepoSetupService setup) + : DbContext, ISolicitorRepository, IReadOnlyCitiesRepository, IRatingsProviderRepository { - private readonly IRepoSetupService _setup; - - public SolicitorsRepository(IRepoSetupService setup) - { - _setup = setup; - } - public DbSet Solicitors { get; set; } public DbSet SolicitorRatings { get; set; } public DbSet SolicitorLocations { get; set; } @@ -23,7 +16,10 @@ internal class SolicitorsRepository : DbContext, ISolicitorRepository, IReadOnly public DbSet Cities { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => _setup.OnConfiguring(optionsBuilder); + => setup.OnConfiguring(optionsBuilder); + + public Task EnsureCreatedAsync(CancellationToken cancellationToken) + => Database.EnsureCreatedAsync(cancellationToken); public IAsyncEnumerable GetAllSolicitorsAsync() => Solicitors @@ -41,104 +37,7 @@ internal class SolicitorsRepository : DbContext, ISolicitorRepository, IReadOnly .Include(solicitor => solicitor.Ratings) .FirstOrDefaultAsync(x => x.SolicitorId == id, cancellationToken); - public async Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken) - { - var matchingSolicitor = await Solicitors - .FirstOrDefaultAsync( - item => item.Name == solicitor.Name, - cancellationToken: cancellationToken); - - if (matchingSolicitor is null) - await AddNewSolicitor(solicitor, cancellationToken); - else - await UpdateExistingSolicitor(solicitor, matchingSolicitor, cancellationToken); - } - - private async Task AddNewSolicitor( - SolicitorData solicitor, - CancellationToken cancellationToken) - { - var newSol = new Solicitor() - { - Name = solicitor.Name, - RelativeUrl = solicitor.UrlPath, - Phone = solicitor.Phone, - Email = solicitor.Email, - Website = solicitor.Website, - ShortDescription = solicitor.ShortDescription - }; - Solicitors.Add(newSol); - - var cityTasks = solicitor.Cities - .Distinct() - .Select(async (city) => - { - var matchingCity = await Cities.FirstOrDefaultAsync( - item => item.Name == city, - cancellationToken: cancellationToken); - - if (matchingCity is null) - { - matchingCity = new City() { Name = city }; - Cities.Add(matchingCity); - newSol.Cities.Add(matchingCity); - } - - matchingCity.Solicitors.Add(newSol); - }); - await Task.WhenAll(cityTasks); - - foreach (var rating in solicitor.Ratings) - { - var newRating = new SolicitorRating() - { - Value = rating.Value, - Maximum = rating.MaxValue, - Provider = rating.RatingProvider, - Solicitor = newSol - }; - SolicitorRatings.Add(newRating); - newSol.Ratings.Add(newRating); - } - - foreach (var location in solicitor.Offices) - { - var newLocation = new SolicitorLocation() - { - Address = location.Address, - Phone = location.Phone, - Solicitor = newSol - }; - SolicitorLocations.Add(newLocation); - newSol.Locations.Add(newLocation); - - foreach (var rating in location.LocationRatings) - { - var newLocRating = new SolicitorLocationRating() - { - Value = rating.value, - Maximum = rating.MaxValue, - Provider = rating.RatingProvider, - SolicitorLocation = newLocation - }; - SolicitorLocationRatings.Add(newLocRating); - newLocation.LocationRatings.Add(newLocRating); - } - } - - await SaveChangesAsync(cancellationToken); - } - - private Task UpdateExistingSolicitor( - SolicitorData solicitor, - Solicitor matchingSolicitor, - CancellationToken cancellationToken) - { - // TODO - return Task.CompletedTask; - } - - public IAsyncEnumerable GetCitiesAsync(CancellationToken cancellationToken) + public IAsyncEnumerable GetCitiesAsync() { return Cities .Select(city => city.Name) @@ -154,4 +53,286 @@ internal class SolicitorsRepository : DbContext, ISolicitorRepository, IReadOnly .ThenBy(x => x) .AsAsyncEnumerable(); } + + public async Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken) + { + var matchingSolicitor = await Solicitors + .Include(existing => existing.Cities) + .Include(existing => existing.Locations) + .ThenInclude(location => location.LocationRatings) + .Include(existing => existing.Ratings) + .FirstOrDefaultAsync( + item => item.Name == solicitor.Name, + cancellationToken: cancellationToken); + + if (matchingSolicitor is null) + await AddNewSolicitor(solicitor, cancellationToken); + else + UpdateExistingSolicitor(solicitor, matchingSolicitor); + + try + { + await SaveChangesAsync(cancellationToken); + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + + public Task RemoveStaleEntriesAsync(TimeSpan staleAge, CancellationToken cancellationToken) + { + var staleTime = DateTime.UtcNow - staleAge; + + Solicitors.RemoveRange(Solicitors.Where(x => x.LastModified < staleTime)); + SolicitorLocations.RemoveRange(SolicitorLocations.Where(x => x.LastModified < staleTime)); + SolicitorRatings.RemoveRange(SolicitorRatings.Where(x => x.LastModified < staleTime)); + SolicitorLocationRatings.RemoveRange(SolicitorLocationRatings.Where(x => x.LastModified < staleTime)); + Cities.RemoveRange(Cities.Where(x => x.LastModified < staleTime)); + + return SaveChangesAsync(cancellationToken); + } + + private async Task AddNewSolicitor( + SolicitorData solicitor, + CancellationToken cancellationToken) + { + var newSol = new Solicitor + { + Name = solicitor.Name, + RelativeUrl = solicitor.UrlPath + }; + UpdateSolicitorRootData(newSol, solicitor); + Solicitors.Add(newSol); + + var cityTasks = solicitor.Cities + .Distinct() + .Select(async city => + { + var matchingCity = await GetCityByNameAsync(city, cancellationToken); + + if (matchingCity is null) + AddNewCityToSolicitor(city, newSol); + else + UpdateExistingCity(matchingCity, newSol); + }); + + await Task.WhenAll(cityTasks); + + foreach (var rating in solicitor.Ratings) + AddNewRatingToSolicitor(rating, newSol); + + foreach (var location in solicitor.Offices) + AddNewLocationToSolicitor(location, newSol); + } + + private void UpdateSolicitorRootData(Solicitor solicitor, SolicitorData data) + { + solicitor.Name = data.Name; + solicitor.RelativeUrl = data.UrlPath; + solicitor.Phone = data.Phone; + solicitor.Email = data.Email; + solicitor.Website = data.Website; + solicitor.ShortDescription = data.ShortDescription; + solicitor.LastModified = DateTime.UtcNow; + } + + private void AddNewCityToSolicitor(string cityName, Solicitor solicitor) + { + var newCity = new City + { + Name = cityName + }; + Cities.Add(newCity); + UpdateExistingCity(newCity, solicitor); + } + + private void AddNewRatingToSolicitor(Rating rating, Solicitor solicitor) + { + var newRating = new SolicitorRating + { + Value = rating.Value, + Maximum = rating.MaxValue, + Provider = rating.RatingProvider, + Solicitor = solicitor, + LastModified = DateTime.UtcNow + }; + SolicitorRatings.Add(newRating); + solicitor.Ratings.Add(newRating); + } + + private void AddNewLocationToSolicitor(Location location, Solicitor solicitor) + { + var newLocation = new SolicitorLocation + { + Address = location.Address, + Phone = location.Phone, + Solicitor = solicitor, + LastModified = DateTime.UtcNow + }; + SolicitorLocations.Add(newLocation); + solicitor.Locations.Add(newLocation); + + foreach (var rating in location.LocationRatings) + AddNewRatingToLocation(rating, newLocation); + } + + private void AddNewRatingToLocation(Rating rating, SolicitorLocation location) + { + var newLocRating = new SolicitorLocationRating + { + Value = rating.Value, + Maximum = rating.MaxValue, + Provider = rating.RatingProvider, + SolicitorLocation = location, + LastModified = DateTime.UtcNow + }; + SolicitorLocationRatings.Add(newLocRating); + location.LocationRatings.Add(newLocRating); + } + + private void UpdateExistingSolicitor( + SolicitorData solicitor, + Solicitor matchingSolicitor) + { + UpdateSolicitorRootData(matchingSolicitor, solicitor); + UpdateExistingCities(solicitor, matchingSolicitor); + UpdateExistingRatings(solicitor, matchingSolicitor); + UpdateExistingLocations(solicitor, matchingSolicitor); + } + + private void UpdateExistingCities( + SolicitorData solicitor, + Solicitor matchingSolicitor) + { + var lostCities = matchingSolicitor.Cities + .Where(city => solicitor.Cities.All(cityName => cityName != city.Name)) + .ToArray(); + + foreach (var city in lostCities) + { + matchingSolicitor.Cities.Remove(city); + city.Solicitors.Remove(matchingSolicitor); + } + foreach (var cityName in solicitor.Cities.Distinct()) + { + var matchingCity = matchingSolicitor.Cities + .FirstOrDefault(city => cityName == city.Name); + + if (matchingCity is null) + AddNewCityToSolicitor(cityName, matchingSolicitor); + else + UpdateExistingCity(matchingCity, matchingSolicitor); + } + } + + private void UpdateExistingRatings( + SolicitorData solicitor, + Solicitor matchingSolicitor) + { + var lostRatings = matchingSolicitor.Ratings + .Where(solicitorRating => solicitor.Ratings.All(rating => rating.RatingProvider != solicitorRating.Provider)) + .ToArray(); + + foreach (var rating in lostRatings) + { + matchingSolicitor.Ratings.Remove(rating); + SolicitorRatings.Remove(rating); + } + + foreach (var rating in solicitor.Ratings) + { + var matchingRating = matchingSolicitor.Ratings + .FirstOrDefault(solicitorRating => rating.RatingProvider == solicitorRating.Provider); + + if (matchingRating is null) + AddNewRatingToSolicitor(rating, matchingSolicitor); + else + UpdateExistingRating(rating, matchingRating); + } + } + + private void UpdateExistingRating(Rating rating, SolicitorRating existingRating) + { + existingRating.Value = rating.Value; + existingRating.Maximum = rating.MaxValue; + existingRating.LastModified = DateTime.UtcNow; + } + + private void UpdateExistingLocations( + SolicitorData solicitor, + Solicitor matchingSolicitor) + { + var lostLocations = matchingSolicitor.Locations + .Where(location => solicitor.Offices.All(office => office.Address != location.Address)) + .ToArray(); + + foreach (var location in lostLocations) + { + matchingSolicitor.Locations.Remove(location); + SolicitorLocations.Remove(location); + } + + foreach (var office in solicitor.Offices) + { + var location = matchingSolicitor.Locations + .FirstOrDefault(location => office.Address == location.Address); + + if (location is null) + AddNewLocationToSolicitor(office, matchingSolicitor); + else + UpdateExistingLocation(office, location); + } + } + + private void UpdateExistingLocation(Location location, SolicitorLocation existingLocation) + { + existingLocation.Phone = location.Phone; + existingLocation.LastModified = DateTime.UtcNow; + + var lostRatings = existingLocation.LocationRatings + .Where(locationRating => location.LocationRatings.All(rating => locationRating.Provider != rating.RatingProvider)) + .ToArray(); + + foreach (var rating in lostRatings) + { + existingLocation.LocationRatings.Remove(rating); + SolicitorLocationRatings.Remove(rating); + } + + foreach (var rating in location.LocationRatings) + { + var existingRating = existingLocation.LocationRatings + .FirstOrDefault(locationRating => locationRating.Provider == rating.RatingProvider); + + if (existingRating is null) + AddNewRatingToLocation(rating, existingLocation); + else + UpdateExistingLocationRating(rating, existingRating); + } + } + + private void UpdateExistingLocationRating(Rating rating, SolicitorLocationRating existingRating) + { + existingRating.Value = rating.Value; + existingRating.Maximum = rating.MaxValue; + existingRating.LastModified = DateTime.UtcNow; + } + + private void UpdateExistingCity(City city, Solicitor solicitor) + { + city.Solicitors.Add(solicitor); + solicitor.Cities.Add(city); + city.LastModified = DateTime.UtcNow; + } + + private Task GetCityByNameAsync(string cityName, CancellationToken cancellationToken) + { + return Cities + .Include(city => city.Solicitors) + .FirstOrDefaultAsync( + city => city.Name == cityName, + cancellationToken: cancellationToken); + } } \ No newline at end of file diff --git a/backend/libs/Solicitors.HtmlParsing/DIExtensions.cs b/backend/libs/Solicitors.HtmlParsing/DIExtensions.cs index 286b841..b12689a 100644 --- a/backend/libs/Solicitors.HtmlParsing/DIExtensions.cs +++ b/backend/libs/Solicitors.HtmlParsing/DIExtensions.cs @@ -1,7 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; namespace Solicitors.HtmlParsing; +[SuppressMessage("ReSharper", "InconsistentNaming")] public static class DIExtensions { public static IServiceCollection AddHtmlParsing(this IServiceCollection services) diff --git a/backend/libs/Solicitors.HtmlParsing/HtmlParser.cs b/backend/libs/Solicitors.HtmlParsing/HtmlParser.cs index 2cbd4cf..7b45a5b 100644 --- a/backend/libs/Solicitors.HtmlParsing/HtmlParser.cs +++ b/backend/libs/Solicitors.HtmlParsing/HtmlParser.cs @@ -33,7 +33,7 @@ internal class HtmlParser : IHtmlParser return ParseHtml(tokenEnumerator); } - private IEnumerable LexHtml(IEnumerator content) + private IEnumerable LexHtml(CharEnumerator content) { var work = ""; while (content.MoveNext()) @@ -85,14 +85,14 @@ internal class HtmlParser : IHtmlParser } } - bool IsVoidOrSelfClosingElement(string token) + private bool IsVoidOrSelfClosingElement(string token) { if (token.StartsWith("")) return true; - var tagName = token.Split(' ').First().Substring(1); + var tagName = token.Split(' ').First()[1..]; if (tagName.EndsWith('>')) - tagName = tagName.Substring(0, tagName.Length - 1); + tagName = tagName[..^1]; return _voidElements.Contains(tagName); } @@ -100,8 +100,8 @@ internal class HtmlParser : IHtmlParser { var attributesString = string.Join(' ', tokenNoBraces.Split(' ').Skip(1)); var attString = ""; - bool inQuote = false; - foreach (char c in attributesString) + var inQuote = false; + foreach (var c in attributesString) { if (!inQuote && c == ' ') { diff --git a/backend/libs/Solicitors.HtmlParsing/Models/HtmlNode.cs b/backend/libs/Solicitors.HtmlParsing/Models/HtmlNode.cs index 050e011..971014f 100644 --- a/backend/libs/Solicitors.HtmlParsing/Models/HtmlNode.cs +++ b/backend/libs/Solicitors.HtmlParsing/Models/HtmlNode.cs @@ -2,19 +2,16 @@ using System.Diagnostics.CodeAnalysis; namespace Solicitors.HtmlParsing.Models; -internal class HtmlNode(string tagName, IHtmlAttribute[] attributes) +internal class HtmlNode(string name, IHtmlAttribute[] attributes) : IHtmlNode { - private readonly IHtmlAttribute[] _attributes = attributes; - private readonly string _tagName = tagName; - public virtual bool TryGetByTagName( string tagName, [NotNullWhen(true)] out IHtmlNode? node, bool noChildren = false) { node = null; - if (tagName == _tagName) + if (tagName == name) node = this; return node is not null; @@ -24,7 +21,7 @@ internal class HtmlNode(string tagName, IHtmlAttribute[] attributes) { get { - var classAttribute = _attributes + var classAttribute = attributes .Where(a => a is HtmlValueAttribute) .Select(a => (a as HtmlValueAttribute)!) .FirstOrDefault(a => a.Name.Equals("class", StringComparison.CurrentCultureIgnoreCase)); @@ -55,7 +52,7 @@ internal class HtmlNode(string tagName, IHtmlAttribute[] attributes) bool noChildren = false) { node = null; - if (tagName == _tagName && Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase)) + if (tagName == name && Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase)) node = this; return node is not null; @@ -75,7 +72,7 @@ internal class HtmlNode(string tagName, IHtmlAttribute[] attributes) public bool HasAttribute(string attributeName) { - return _attributes + return attributes .Any(a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase)); } @@ -83,7 +80,7 @@ internal class HtmlNode(string tagName, IHtmlAttribute[] attributes) { attributeValue = null; - var matchingAttribute = _attributes.FirstOrDefault( + var matchingAttribute = attributes.FirstOrDefault( a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase) ); diff --git a/backend/libs/Solicitors.HtmlParsing/Models/ParentHtmlNode.cs b/backend/libs/Solicitors.HtmlParsing/Models/ParentHtmlNode.cs index 61a0eb7..ae229b1 100644 --- a/backend/libs/Solicitors.HtmlParsing/Models/ParentHtmlNode.cs +++ b/backend/libs/Solicitors.HtmlParsing/Models/ParentHtmlNode.cs @@ -5,8 +5,6 @@ namespace Solicitors.HtmlParsing.Models; internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtmlNode[] children) : HtmlNode(tagName, attributes) { - private readonly IHtmlNode[] _children = children; - public override bool TryGetByTagName( string tagName, [NotNullWhen(true)] out IHtmlNode? node, @@ -15,7 +13,7 @@ internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtml node = null; if (!base.TryGetByTagName(tagName, out node, noChildren) && !noChildren) { - var nodesToCheck = new Queue(_children); + var nodesToCheck = new Queue(children); while (nodesToCheck.TryDequeue(out var child)) { if (child.TryGetByTagName(tagName, out var childNode, true)) @@ -41,7 +39,7 @@ internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtml node = null; if (!base.TryGetByClass(className, out node, noChildren) && !noChildren) { - var nodesToCheck = new Queue(_children); + var nodesToCheck = new Queue(children); while (nodesToCheck.TryDequeue(out var child)) { if (child.TryGetByClass(className, out var childNode, true)) @@ -68,7 +66,7 @@ internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtml node = null; if (!base.TryGetByTagNameAndClass(tagName, className, out node, noChildren) && !noChildren) { - var nodesToCheck = new Queue(_children); + var nodesToCheck = new Queue(children); while (nodesToCheck.TryDequeue(out var child)) { if (child.TryGetByTagNameAndClass(tagName, className, out var childNode, true)) @@ -86,9 +84,9 @@ internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtml return node is not null; } - public override bool TryGetChildren([NotNullWhen(true)] out IEnumerable? children) + public override bool TryGetChildren([NotNullWhen(true)] out IEnumerable? children1) { - children = _children; - return _children.Length != 0; + children1 = children; + return children.Length != 0; } } \ No newline at end of file diff --git a/backend/libs/Solicitors.HtmlParsing/Models/StringNode.cs b/backend/libs/Solicitors.HtmlParsing/Models/StringNode.cs index 4669fb7..6b2009c 100644 --- a/backend/libs/Solicitors.HtmlParsing/Models/StringNode.cs +++ b/backend/libs/Solicitors.HtmlParsing/Models/StringNode.cs @@ -5,8 +5,6 @@ namespace Solicitors.HtmlParsing.Models; internal class StringNode(string content) : IHtmlNode { - private readonly string _content = content; - public bool TryGetByTagName( string tagName, [NotNullWhen(true)] out IHtmlNode? node, @@ -37,7 +35,7 @@ internal class StringNode(string content) public bool TryGetText([NotNullWhen(true)] out string? text) { - text = _content; + text = content; return !string.IsNullOrEmpty(text); } diff --git a/frontend/src/functions/getSolicitor.ts b/frontend/src/functions/getSolicitor.ts index 382843a..6c32363 100644 --- a/frontend/src/functions/getSolicitor.ts +++ b/frontend/src/functions/getSolicitor.ts @@ -2,6 +2,6 @@ import { SolicitorInfo } from "../models/solicitorSummary"; import api from "./axios"; export default async function GetSolicitor(id : string) : Promise { - const response = await api.get(`/conveyancors/${id}`); + const response = await api.get(`/solicitors/${id}`); return response.data as SolicitorInfo; } \ No newline at end of file diff --git a/frontend/src/functions/getSolicitors.ts b/frontend/src/functions/getSolicitors.ts index c69a5a4..439b809 100644 --- a/frontend/src/functions/getSolicitors.ts +++ b/frontend/src/functions/getSolicitors.ts @@ -17,6 +17,6 @@ export default async function GetSolicitors(filters : FilterState) { if (filters.sortBy != null) { queryString += `&orderingType=${filters.sortBy}`; } - const response = await api.get(`/conveyancors?${queryString}`); + const response = await api.get(`/solicitors?${queryString}`); return response.data as PaginationResponse; } \ No newline at end of file