initial commit
This commit is contained in:
commit
9788479ae7
482
.gitignore
vendored
Normal file
482
.gitignore
vendored
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from `dotnet new gitignore`
|
||||||
|
|
||||||
|
# dotenv files
|
||||||
|
.env
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# Tye
|
||||||
|
.tye/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||||
|
!Directory.Build.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
|
*.pubxml
|
||||||
|
*.publishproj
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||||
|
*.vbp
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
|
*.dsw
|
||||||
|
*.dsp
|
||||||
|
|
||||||
|
# Visual Studio 6 technical files
|
||||||
|
*.ncb
|
||||||
|
*.aps
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
# tools/**
|
||||||
|
# !tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
.localhistory/
|
||||||
|
|
||||||
|
# Visual Studio History (VSHistory) files
|
||||||
|
.vshistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# VS Code files for those working on multiple tools
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Windows Installer files from build outputs
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# JetBrains Rider
|
||||||
|
*.sln.iml
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
##
|
||||||
|
## Visual studio for Mac
|
||||||
|
##
|
||||||
|
|
||||||
|
|
||||||
|
# globs
|
||||||
|
Makefile.in
|
||||||
|
*.userprefs
|
||||||
|
*.usertasks
|
||||||
|
config.make
|
||||||
|
config.status
|
||||||
|
aclocal.m4
|
||||||
|
install-sh
|
||||||
|
autom4te.cache/
|
||||||
|
*.tar.gz
|
||||||
|
tarballs/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# Vim temporary swap files
|
||||||
|
*.swp
|
||||||
113
README.md
Normal file
113
README.md
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# README
|
||||||
|
This repo contains Fox's submission for InfoTrack's development project. The backend (found in `/backend`) is a dotnet 10 minimal api, and the frontend is a single-page webapp made using preact and vite.
|
||||||
|
|
||||||
|
A sample of this project will be deployed [here](http://solicitors.starfahx.com) for the next few weeks, should the steps [below](#running-the-project) not work on your machine.
|
||||||
|
|
||||||
|
## Using the App
|
||||||
|
The frontend project provides a quick way to search through the cached data. The list of firms can be filtered by name (case-insensitive, matches any part of the name) to quickly find a desired firm. Additional fields allow the user to filter results by:
|
||||||
|
- City: this field is a multi-select, and will return firms in any of the cities selected; if none are selected, it returns firms from all cities.
|
||||||
|
- Rating: this field allows the user to select a minimum rating, and a rating provider to filter by. Only firms who have a rating provided by the selected provider will be returned, unless the minimum rating field is de-selected.
|
||||||
|
|
||||||
|
These fields are disabled if the option to search by name only is selected.
|
||||||
|
|
||||||
|
Finally, the user can change the ordering of results and the number displayed per page.
|
||||||
|
|
||||||
|
Clicking on a result brings up a modal display of all the relevant ratings and contact information available for the firm.
|
||||||
|
|
||||||
|
## Running the Project
|
||||||
|
The project is designed to be run using `docker compose`. Clone the project, and add a `compose.yaml` file in the root directory. A sample compose file might look like this:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
VITE_API_URL: http://localhost:5123
|
||||||
|
ports:
|
||||||
|
- "5124:80"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
ports:
|
||||||
|
- "5123:8080"
|
||||||
|
volumes:
|
||||||
|
- "/path/to/data:/data"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend container requires a volume mapped to `/data`, which contains an `apiSettings.json` configuration file, necessary for configuring the api. A sample config file might look like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Database": {
|
||||||
|
"Type": "sqlite",
|
||||||
|
"DbPath": "/data/solicitors.db"
|
||||||
|
},
|
||||||
|
"Imports": {
|
||||||
|
"ImportMinutes": 5,
|
||||||
|
"RemoveMinutes": 120,
|
||||||
|
"StaleMinutes": 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Database` node must contain a `Type` field, which must have the value `"sqlite"` or `"inmemory"`, to configure a database of the appropriate kind.
|
||||||
|
|
||||||
|
For a Sqlite database, the field `"DbPath"`specifies the path to the .db file. It is recommended to choose a file in `/data`, as this is a volume already mounted and can be examined outside the container.
|
||||||
|
|
||||||
|
For an In Memory database, the field `"DbName"` specifies the name to give to the database.
|
||||||
|
|
||||||
|
The `Imports` node must contain the following fields:
|
||||||
|
- `"ImportMinutes"`: an integer number of minutes on which to run the cache import job. The target website, solicitors.com, does not provide consistent search results, so frequent searches will ensure the most complete cache possible.
|
||||||
|
- `"RemoveMinutes"`: an integer number of minutes on which to run the cache cleanup job. This job removes stale items from the cache. It is recommended that this job run much less often than the import, for the reason above.
|
||||||
|
- `"StaleMinutes"`: an integer number of minutes since modified beyond which a cached item is considered stale, and removed by the cleanup job.
|
||||||
|
|
||||||
|
With both the configuration file and the compose file in place, running `docker compose up -d` will start the containerised applications. They can be shut down with `docker compose down`. If you've pulled a newer version of the source code, `docker compose up -d --build` should be used to relaunch the applications.
|
||||||
|
|
||||||
|
## Additional Notes on Implementation
|
||||||
|
I _somewhat_ exceeded the 1-2 hours mentioned in the document, if the amount of code here + my aversion to AI doesn't make that clear, but given in the interview it was suggested that this could take a couple of weeks I figured that was a guideline and since I didn't have much better to do for this last week I decided to put a bit of effort in. Hope that's okay! It's taken roughly a workweek to get this together, excluding time spent filing bug reports with Fresh (my first choice for a frontend framework).
|
||||||
|
|
||||||
|
I've written some notes on things I've enjoyed doing + want to show off, as well as things that have suffered for the time constraints. I then looked at how much I wrote and realised it was rather a lot. Sorry! Feel free to skim it.
|
||||||
|
|
||||||
|
### HTML Parsing
|
||||||
|
For the web-scraping part of this project, I've done my best to avoid third-party libraries as requested. To achieve this, I've written an _extremely limited_ HTML parsing library, as I wasn't able to find an easy equivalent in the standard libraries for dotnet. This library uses a design I've borrowed from compiler design (I'm currently working my way through a textbook on compiler design so that was quite handy!) where first the HTML is lexed into tokens and then those tokens are parsed in a pseudo-grammar to form a nested tree of nodes.
|
||||||
|
|
||||||
|
I made use of a C# feature here that I made use of while working through that textbook: by leveraging deferred execution, I'm able to effectively perform a single-pass parsing. I'm not sure this actually has much of a performance improvement, but it seems silly to build up a list of nodes just to immediately enumerate through it again.
|
||||||
|
|
||||||
|
Due to the time constratints, this is really quite messy code and I'm not happy with the api it provides for actually scanning through the HTML tree, but it works for now. I'd love to come back and revisit this at some point, honestly, it was a really fun little project.
|
||||||
|
|
||||||
|
### Cache Store
|
||||||
|
I've used EFCore as my ORM of choice, because it's super easy to use, and this project finally gave me the opportunity to implement something I've never had an opportunity to do in the past: runtime switching of EFCore providers! I've wanted to build an app that allows the user to define (in a compose file or similar) where the data is stored for a long time, so I'm very glad I got a chance to do that here.
|
||||||
|
|
||||||
|
It's good to know that `EnsureCreatedAsync` works the way I expected for making a new sqlite db, and avoids messing around with migrations too much. Obviously, that's only possible because the schema for this project is pretty static: solving that problem for an actively-developed version of this app, and one that uses a SQL Server or Postgres provider, would probably require a fair bit more engineering.
|
||||||
|
|
||||||
|
### General Architecture
|
||||||
|
Broadly I try to architect my projects (as can be seen here) into libraries which expose abstractions publicly and keep implementations internal, registering them with a DI container. I think this approach is cleaner than having a bunch of .Abstractions projects lying around when those aren't absolutely necessary!
|
||||||
|
|
||||||
|
The original idea was that the `Solicitors.Core` library would contain service abstractions that could be referenced by libraries for the cache store and for the cache build separately, which seems to have done the trick. This approach got a little confusing at times with the models namespaces, though, so I could probably do a better job of keeping those clean.
|
||||||
|
|
||||||
|
There's also some things that ended up in Core (e.g. the comparers for ordering results) that I don't think really belonged there, which was the result of not really having a place for API-level abstractions to belong.
|
||||||
|
|
||||||
|
Running the imports on a timer within the api is probably not how I'd want to architect this project if it were going to be developed further, but I've gone with that based on the time constraints. In an ideal world there'd be a read-only REST api app + another service app that could be scheduled by the OS. I settled on Quartz.Net to provide scheduling functionality for the import only because I'd heard of it and knew it would work, though I think it's a bit overkill for what I'm trying to do here.
|
||||||
|
|
||||||
|
### API Design
|
||||||
|
A weak point of this project, which is a shame because it's something I care quite a bit about. I'd love to take the time to do up the API nicely, handle errors + validate input etc etc, but it really seemed quite unnecessary for this project, especially since the API exists only to be consumed by the UI.
|
||||||
|
|
||||||
|
### Frontend Frameworks
|
||||||
|
As mentioned, I was going to use Deno + Fresh as my frontend framework of choice: I love preact as a lightweight, fast alternative to react, and Fresh using it by default has really won me over. Fresh's approach to server-side rendering is really fun, and I'd spent a while on this project learning all about Fresh's Partials system, which I intended to use for the results display.
|
||||||
|
|
||||||
|
Unfortunately, there's a bug in Fresh that I'd occasionally run into before that means using css/scss modules is deeply inconsistent, which is my go-to pattern if I'm not using a component library. I'm not a big fan of tailwind, and because Deno and Fresh are fairly immature it's quite hard to find a non-tailwind component/styling library that works with them.
|
||||||
|
|
||||||
|
In the end I fell back on regular preact, hosted by vite (not because I know it very well. It's just what Fresh uses under the hood for some things so I thought porting my work would be easy. It wasn't.) I've used MUI as a component library because after losing all that time I just wanted to get it done quickly, and it's a framework I've used before with some success. It's a bit dated but it gets the job done!
|
||||||
|
|
||||||
|
### (Unit) Testing
|
||||||
|
I really did not have time for this. I _should_ have absolutely done this as TDD, but given I started with the problem of the HTML Parsing and was just writing that out in a scratchpad to make something that even stood a chance of working I ended up skipping that.
|
||||||
|
|
||||||
|
I've added a couple of unit tests more to demonstrate ability and comfort with them than anything else.
|
||||||
|
|
||||||
|
I've done some reasonably thorough manual testing, and pestered friends and family to try to break the site to make sure I hadn't missed anything obvious. I'm pretty confident it'll work! But this is my first time making an app designed to be consumed by `docker compose` so it might just fall apart for you. I've tested deployment both on my desktop and on one of my servers, using various different configurations and on a couple of different Linux distributions.
|
||||||
|
|
||||||
|
## Closing Remarks
|
||||||
|
Sorry if I took too long over this. It's been nice to have something serious to work on for the last week, so I wanted to enjoy it while it lasted! Thanks for considering me for the role and I hope I get to speak with you soon.
|
||||||
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /App
|
||||||
|
|
||||||
|
# Copy everything
|
||||||
|
COPY . ./
|
||||||
|
# Restore as distinct layers
|
||||||
|
RUN dotnet restore
|
||||||
|
# Build and publish a release
|
||||||
|
RUN dotnet publish -o out
|
||||||
|
|
||||||
|
# Build runtime image
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
|
VOLUME /data
|
||||||
|
WORKDIR /App
|
||||||
|
COPY --from=build /App/out .
|
||||||
|
ENTRYPOINT ["dotnet", "Solicitors.Api.dll"]
|
||||||
14
backend/SolicitorsApi.slnx
Normal file
14
backend/SolicitorsApi.slnx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/app/">
|
||||||
|
<Project Path="app/Solicitors.Api/Solicitors.Api.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/libs/">
|
||||||
|
<Project Path="libs/Solicitors.CacheBuild/Solicitors.CacheBuild.csproj" />
|
||||||
|
<Project Path="libs/Solicitors.Core/Solicitors.Core.csproj" />
|
||||||
|
<Project Path="libs/Solicitors.Data/Solicitors.Data.csproj" />
|
||||||
|
<Project Path="libs/Solicitors.HtmlParsing/Solicitors.HtmlParsing.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/Solicitors.Core.Tests/Solicitors.Core.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
17
backend/app/Solicitors.Api/Endpoints/CitiesEndpoints.cs
Normal file
17
backend/app/Solicitors.Api/Endpoints/CitiesEndpoints.cs
Normal file
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/app/Solicitors.Api/Endpoints/SolicitorsEndpoints.cs
Normal file
60
backend/app/Solicitors.Api/Endpoints/SolicitorsEndpoints.cs
Normal file
@ -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<Solicitor>? filter = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(nameFilter))
|
||||||
|
filter = new WrapperFilter<Solicitor>(filter, solicitor => solicitor.Name.Contains(nameFilter, StringComparison.CurrentCultureIgnoreCase));
|
||||||
|
|
||||||
|
if (cities is not null && cities.Length > 0)
|
||||||
|
filter = new WrapperFilter<Solicitor>(filter, solicitor => cities.Any(city => solicitor.Cities.Any(solCity => solCity.Name == city)));
|
||||||
|
|
||||||
|
if (minRating > 0)
|
||||||
|
filter = new WrapperFilter<Solicitor>(
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/app/Solicitors.Api/Program.cs
Normal file
42
backend/app/Solicitors.Api/Program.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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<ImportConfiguration>(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();
|
||||||
|
app.UseCors();
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
await scope.UseCacheBuildAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.Run();
|
||||||
23
backend/app/Solicitors.Api/Properties/launchSettings.json
Normal file
23
backend/app/Solicitors.Api/Properties/launchSettings.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5254",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7226;http://localhost:5254",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/app/Solicitors.Api/Solicitors.Api.csproj
Normal file
22
backend/app/Solicitors.Api/Solicitors.Api.csproj
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\libs\Solicitors.CacheBuild\Solicitors.CacheBuild.csproj" />
|
||||||
|
<ProjectReference Include="..\..\libs\Solicitors.Data\Solicitors.Data.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
backend/app/Solicitors.Api/Solicitors.Api.http
Normal file
6
backend/app/Solicitors.Api/Solicitors.Api.http
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
@Solicitors.Api_HostAddress = http://localhost:5254
|
||||||
|
|
||||||
|
GET {{Solicitors.Api_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
9
backend/app/Solicitors.Api/appsettings.json
Normal file
9
backend/app/Solicitors.Api/appsettings.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
54
backend/libs/Solicitors.CacheBuild/DIExtensions.cs
Normal file
54
backend/libs/Solicitors.CacheBuild/DIExtensions.cs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace Solicitors.CacheBuild;
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
|
public static class DIExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddCacheBuild(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
return services
|
||||||
|
.AddScoped<ISolicitorParser, SolicitorsDotCom.SolicitorParser>()
|
||||||
|
.AddScoped<ISolicitorImporter, SolicitorImporter>()
|
||||||
|
.AddQuartz()
|
||||||
|
.AddQuartzHostedService(opt => opt.WaitForJobsToComplete = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task UseCacheBuildAsync(this IServiceScope scope, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var factory = scope.ServiceProvider.GetRequiredService<ISchedulerFactory>();
|
||||||
|
var options = scope.ServiceProvider.GetRequiredService<IOptions<ImportConfiguration>>().Value;
|
||||||
|
var scheduler = await factory.GetScheduler(cancellationToken);
|
||||||
|
|
||||||
|
var importJob = JobBuilder.Create<ImportRunner>()
|
||||||
|
.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<StaleDataRemover>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
backend/libs/Solicitors.CacheBuild/ISolicitorImporter.cs
Normal file
6
backend/libs/Solicitors.CacheBuild/ISolicitorImporter.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.CacheBuild;
|
||||||
|
|
||||||
|
public interface ISolicitorImporter
|
||||||
|
{
|
||||||
|
Task RunFullImport(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
9
backend/libs/Solicitors.CacheBuild/ISolicitorParser.cs
Normal file
9
backend/libs/Solicitors.CacheBuild/ISolicitorParser.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
using Solicitors.Core.Models.Imports;
|
||||||
|
|
||||||
|
namespace Solicitors.CacheBuild;
|
||||||
|
|
||||||
|
internal interface ISolicitorParser
|
||||||
|
{
|
||||||
|
Task<SolicitorData[]> GetSolicitorsAsync(
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
15
backend/libs/Solicitors.CacheBuild/ImportRunner.cs
Normal file
15
backend/libs/Solicitors.CacheBuild/ImportRunner.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/libs/Solicitors.CacheBuild/SolicitorImporter.cs
Normal file
20
backend/libs/Solicitors.CacheBuild/SolicitorImporter.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using Solicitors.Core.Data;
|
||||||
|
using Solicitors.Core.Models.Imports;
|
||||||
|
|
||||||
|
namespace Solicitors.CacheBuild;
|
||||||
|
|
||||||
|
internal class SolicitorImporter(
|
||||||
|
ISolicitorParser parser,
|
||||||
|
ISolicitorRepository repository) : ISolicitorImporter
|
||||||
|
{
|
||||||
|
public async Task RunFullImport(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var solicitor in await parser.GetSolicitorsAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
await RunImport(solicitor, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task RunImport(SolicitorData solicitor, CancellationToken cancellationToken)
|
||||||
|
=> repository.AddOrUpdateSolicitorAsync(solicitor, cancellationToken);
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Solicitors.Core\Solicitors.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Quartz" Version="3.18.1" />
|
||||||
|
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.18.1" />
|
||||||
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="Microsoft.Extensions.Hosting.Abstractions">
|
||||||
|
<HintPath>..\..\..\..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\10.0.7\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
namespace Solicitors.CacheBuild.SolicitorsDotCom;
|
||||||
|
|
||||||
|
internal static class Consts
|
||||||
|
{
|
||||||
|
public const string BaseUrl = "https://www.solicitors.com";
|
||||||
|
public static readonly string[] Locations =
|
||||||
|
[
|
||||||
|
"london", "birmingham", "leeds", "manchester", "sheffield", "bradford", "liverpool", "bristol"
|
||||||
|
];
|
||||||
|
|
||||||
|
public const decimal MaxRating = 5.0m;
|
||||||
|
public const string Name = "Solicitors.com";
|
||||||
|
public const string IconPath = "/images/logo.svg";
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
namespace Solicitors.CacheBuild.SolicitorsDotCom;
|
||||||
|
|
||||||
|
internal class SolicitorBuilder
|
||||||
|
{
|
||||||
|
private readonly HashSet<string> _baseLocations = [];
|
||||||
|
|
||||||
|
public SolicitorBuilder(
|
||||||
|
string name,
|
||||||
|
string path,
|
||||||
|
string? shortDesc,
|
||||||
|
string baseLocation)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
ShortDescription = shortDesc;
|
||||||
|
_baseLocations.Add(baseLocation);
|
||||||
|
|
||||||
|
Path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddBaseLocation(string baseLocation)
|
||||||
|
{
|
||||||
|
_baseLocations.Add(baseLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Path { get; }
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public string? ShortDescription { get; }
|
||||||
|
|
||||||
|
public string[] BaseLocations => _baseLocations.ToArray();
|
||||||
|
}
|
||||||
@ -0,0 +1,315 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Solicitors.Core.Misc;
|
||||||
|
using Solicitors.Core.Models.Imports;
|
||||||
|
using Solicitors.HtmlParsing;
|
||||||
|
using Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.CacheBuild.SolicitorsDotCom;
|
||||||
|
|
||||||
|
internal class SolicitorParser : ISolicitorParser
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly IHtmlParser _htmlParser;
|
||||||
|
|
||||||
|
public SolicitorParser(
|
||||||
|
HttpClient client,
|
||||||
|
IHtmlParser htmlParser)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_client.BaseAddress = new Uri(Consts.BaseUrl);
|
||||||
|
|
||||||
|
_htmlParser = htmlParser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SolicitorData[]> GetSolicitorsAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var builders = new Dictionary<string, SolicitorBuilder>();
|
||||||
|
|
||||||
|
foreach (var location in Consts.Locations)
|
||||||
|
{
|
||||||
|
var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/conveyancing+{location}.html");
|
||||||
|
httpRequest.Headers.Add("User-Agent", "Solicitors API Cache");
|
||||||
|
var httpResponse = await _client.SendAsync(httpRequest, cancellationToken);
|
||||||
|
var html = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var nodes = _htmlParser.ParseHtml(html);
|
||||||
|
IHtmlNode? mainNode = null;
|
||||||
|
foreach (var node in nodes)
|
||||||
|
if (node.TryGetByTagName("main", out mainNode))
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (TryGetResultSection(mainNode, out var results))
|
||||||
|
ParseResultsSection(results, location, builders);
|
||||||
|
}
|
||||||
|
|
||||||
|
var batches = builders.Values
|
||||||
|
.Select(builder => GetFullSolicitorDataAsync(builder, cancellationToken))
|
||||||
|
.Batch(5);
|
||||||
|
|
||||||
|
var solicitors = new List<SolicitorData?>();
|
||||||
|
foreach (var batch in batches)
|
||||||
|
solicitors.AddRange(await Task.WhenAll(batch));
|
||||||
|
|
||||||
|
return solicitors
|
||||||
|
.Where(x => x is not null)
|
||||||
|
.Cast<SolicitorData>()
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetResultSection(
|
||||||
|
IHtmlNode? mainNode,
|
||||||
|
[NotNullWhen(true)] out IEnumerable<IHtmlNode>? results)
|
||||||
|
{
|
||||||
|
results = null;
|
||||||
|
|
||||||
|
// going one level at a time helps a little with performance here, at the cost of messier code
|
||||||
|
return mainNode is not null
|
||||||
|
&& mainNode.TryGetByTagNameAndClass("div", "content-holder", out var contentHolderNode)
|
||||||
|
&& contentHolderNode.TryGetByTagNameAndClass("div", "container", out var containerNode)
|
||||||
|
&& containerNode.TryGetByTagNameAndClass("div", "content", out var contentNode)
|
||||||
|
&& contentNode.TryGetByTagNameAndClass("div", "result-section", out var resultSectionNode)
|
||||||
|
&& resultSectionNode.TryGetChildren(out results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ParseResultsSection(
|
||||||
|
IEnumerable<IHtmlNode> results,
|
||||||
|
string location,
|
||||||
|
Dictionary<string, SolicitorBuilder> builders)
|
||||||
|
{
|
||||||
|
foreach (var result in results)
|
||||||
|
{
|
||||||
|
if (result.TryGetByClass("result-item", out var resultItem, true)
|
||||||
|
&& TryParseToNewBuilder(resultItem, location, out var builder))
|
||||||
|
{
|
||||||
|
if (!builders.TryAdd(builder.Path, builder))
|
||||||
|
builders[builder.Path].AddBaseLocation(location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryParseToNewBuilder(
|
||||||
|
IHtmlNode resultItemNode,
|
||||||
|
string baseLocation,
|
||||||
|
[NotNullWhen(true)] out SolicitorBuilder? builder)
|
||||||
|
{
|
||||||
|
builder = null;
|
||||||
|
if (!resultItemNode.TryGetByTagNameAndClass("a", "link-map", out var linkMapNode)
|
||||||
|
|| !linkMapNode.TryGetAttributeValue("href", out var uniqueUrl)
|
||||||
|
|| !resultItemNode.TryGetByTagNameAndClass("span", "h2", out var titleNode)
|
||||||
|
|| !titleNode.TryGetChildren(out var titleChildren))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string? name = null;
|
||||||
|
foreach (var child in titleChildren)
|
||||||
|
if (child.TryGetText(out name))
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (name is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string? shortDesc = null;
|
||||||
|
if (resultItemNode.TryGetByTagName("p", out var pNode)
|
||||||
|
&& pNode.TryGetChildren(out var pChildren))
|
||||||
|
foreach (var child in pChildren)
|
||||||
|
if (child.TryGetText(out shortDesc))
|
||||||
|
break;
|
||||||
|
|
||||||
|
builder = new SolicitorBuilder(name, uniqueUrl, shortDesc, baseLocation);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SolicitorData?> GetFullSolicitorDataAsync(
|
||||||
|
SolicitorBuilder builder,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
|
||||||
|
var httpRequest = new HttpRequestMessage(HttpMethod.Get, builder.Path);
|
||||||
|
httpRequest.Headers.Add("User-Agent", "Solicitors API Cache");
|
||||||
|
var httpResponse = await _client.SendAsync(httpRequest, cancellationToken);
|
||||||
|
var html = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var nodes = _htmlParser.ParseHtml(html);
|
||||||
|
IHtmlNode? mainNode = null;
|
||||||
|
foreach (var node in nodes)
|
||||||
|
if (node.TryGetByTagName("main", out mainNode))
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (mainNode is null || !mainNode.TryGetByTagNameAndClass("div", "content-block", out var contentBlockNode))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Location[] offices = [];
|
||||||
|
if (contentBlockNode.TryGetByTagNameAndClass("div", "office-item", out var officeItemNode)
|
||||||
|
&& officeItemNode.TryGetChildren(out var officeItems))
|
||||||
|
{
|
||||||
|
offices = ParseOffices(officeItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
Rating[] ratings = [];
|
||||||
|
string? phone = null;
|
||||||
|
string? email = null;
|
||||||
|
string? website = null;
|
||||||
|
if (mainNode.TryGetByTagNameAndClass("div", "side-holder", out var sidebarNode)
|
||||||
|
&& sidebarNode.TryGetChildren(out var sidebarChildren))
|
||||||
|
{
|
||||||
|
ParseSidebar(sidebarChildren, out phone, out email, out website, out ratings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SolicitorData
|
||||||
|
{
|
||||||
|
Name = builder.Name,
|
||||||
|
UrlPath = builder.Path,
|
||||||
|
ShortDescription = builder.ShortDescription,
|
||||||
|
Phone = phone,
|
||||||
|
Email = email,
|
||||||
|
Website = website,
|
||||||
|
Ratings = ratings,
|
||||||
|
Offices = offices,
|
||||||
|
Cities = builder.BaseLocations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private Location[] ParseOffices(IEnumerable<IHtmlNode> officeNodes)
|
||||||
|
=> officeNodes
|
||||||
|
.Select(ParseOffice)
|
||||||
|
.Where(x => x is not null)
|
||||||
|
.Cast<Location>()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private Location? ParseOffice(IHtmlNode officeNode)
|
||||||
|
{
|
||||||
|
if (!officeNode.TryGetChildren(out var officeChildren)
|
||||||
|
|| !officeNode.TryGetByTagName("address", out var addressNode)
|
||||||
|
|| !addressNode.TryGetChildren(out var addressLines))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Location? location = null;
|
||||||
|
var address = "";
|
||||||
|
foreach (var line in addressLines)
|
||||||
|
{
|
||||||
|
if (line.TryGetText(out var lineText))
|
||||||
|
address += lineText;
|
||||||
|
else if (line.TryGetByTagName("br", out _, true))
|
||||||
|
address += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(address))
|
||||||
|
address = null;
|
||||||
|
|
||||||
|
string? phone = null;
|
||||||
|
foreach (var child in officeChildren)
|
||||||
|
{
|
||||||
|
if (child.TryGetByTagName("a", out var anchor)
|
||||||
|
&& anchor.TryGetAttributeValue("href", out var telString)
|
||||||
|
&& telString.StartsWith("\"tel:")
|
||||||
|
&& telString.Length > 5)
|
||||||
|
{
|
||||||
|
phone = telString.Substring(5, telString.Length - 6);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (address is not null && phone is not null)
|
||||||
|
location = new Location(address, phone, []);
|
||||||
|
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ParseSidebar(
|
||||||
|
IEnumerable<IHtmlNode> sidebarChildren,
|
||||||
|
out string? phone,
|
||||||
|
out string? email,
|
||||||
|
out string? website,
|
||||||
|
out Rating[] ratings)
|
||||||
|
{
|
||||||
|
List<Rating> ratingsList = [];
|
||||||
|
phone = null;
|
||||||
|
website = null;
|
||||||
|
email = null;
|
||||||
|
|
||||||
|
foreach (var node in sidebarChildren)
|
||||||
|
{
|
||||||
|
var isRevCount = node.TryGetByTagNameAndClass("div", "rev-count", out _, true);
|
||||||
|
var isLinksHolder = node.TryGetByTagNameAndClass("div", "links-holder", out _, true);
|
||||||
|
if ((!isRevCount && !isLinksHolder)
|
||||||
|
|| !node.TryGetChildren(out var children))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (isLinksHolder)
|
||||||
|
{
|
||||||
|
if (node.TryGetByTagNameAndClass("a", "website", out var websiteNode)
|
||||||
|
&& websiteNode.TryGetAttributeValue("href", out website))
|
||||||
|
website = website.Trim('"');
|
||||||
|
|
||||||
|
if (node.TryGetByTagNameAndClass("a", "phone", out var phoneNode)
|
||||||
|
&& phoneNode.TryGetAttributeValue("href", out var phoneStr)
|
||||||
|
&& phoneStr.StartsWith("\"tel:")
|
||||||
|
&& phoneStr.Length > 5)
|
||||||
|
{
|
||||||
|
phone = phoneStr.Substring(5, phoneStr.Length - 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var childrenArray = children as IHtmlNode[] ?? children.ToArray();
|
||||||
|
foreach (var child in childrenArray.Where(x => x.TryGetByClass("rev-box", out _, true)))
|
||||||
|
{
|
||||||
|
if (child.TryGetAttributeValue("title", out var ratingString)
|
||||||
|
&& 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))
|
||||||
|
{
|
||||||
|
ratingsList.Add(new Rating(rating, maxRating, ratingProvider, imgSrc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingsList.Count == 0)
|
||||||
|
{
|
||||||
|
foreach (var child in childrenArray.Where(x => !x.HasAttribute("class")))
|
||||||
|
{
|
||||||
|
if (child.TryGetChildren(out var grandChildren))
|
||||||
|
foreach (var grandChild in grandChildren)
|
||||||
|
if (grandChild.TryGetText(out var textContent)
|
||||||
|
&& TryParseOwnRatingString(textContent, out var rating))
|
||||||
|
ratingsList.Add(new Rating(rating, Consts.MaxRating, Consts.Name, Consts.IconPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ratings = ratingsList.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool TryParseRatingString(string ratingString, out decimal rating, out decimal maxRating)
|
||||||
|
{
|
||||||
|
rating = 0;
|
||||||
|
maxRating = 0;
|
||||||
|
var parts = ratingString.Trim('"').Split(" / ");
|
||||||
|
if (parts.Length != 2)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return decimal.TryParse(parts[0], out rating) && decimal.TryParse(parts[1], out maxRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryParseRatingSrc(string imgSrc, out string provider)
|
||||||
|
{
|
||||||
|
provider = "";
|
||||||
|
imgSrc = imgSrc.Trim('"');
|
||||||
|
const string prefix = "/images/logo-";
|
||||||
|
if (!imgSrc.StartsWith(prefix))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var providerAndExtension = imgSrc[prefix.Length..];
|
||||||
|
provider = providerAndExtension.Split('.').First();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TryParseOwnRatingString(string ratingString, out decimal rating)
|
||||||
|
{
|
||||||
|
rating = 0;
|
||||||
|
const string prefix = "Average review score : ";
|
||||||
|
if (!ratingString.StartsWith(prefix))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var ratingPart = ratingString[prefix.Length..];
|
||||||
|
return decimal.TryParse(ratingPart, out rating);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/libs/Solicitors.CacheBuild/StaleDataRemover.cs
Normal file
19
backend/libs/Solicitors.CacheBuild/StaleDataRemover.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Quartz;
|
||||||
|
using Solicitors.Core.Data;
|
||||||
|
|
||||||
|
namespace Solicitors.CacheBuild;
|
||||||
|
|
||||||
|
internal class StaleDataRemover(
|
||||||
|
IOptions<ImportConfiguration> config,
|
||||||
|
ISolicitorRepository repository) : IJob
|
||||||
|
{
|
||||||
|
private readonly ImportConfiguration _config = config.Value;
|
||||||
|
|
||||||
|
public Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
return repository.RemoveStaleEntriesAsync(
|
||||||
|
TimeSpan.FromMinutes(_config.StaleMinutes),
|
||||||
|
context.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/libs/Solicitors.Core/DIExtensions.cs
Normal file
19
backend/libs/Solicitors.Core/DIExtensions.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Solicitors.Core.Ordering;
|
||||||
|
|
||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
|
public static class DIExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddCore(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
return services
|
||||||
|
.AddScoped<ISolicitorService, SolicitorService>()
|
||||||
|
.AddScoped<IReadOnlySolicitorService>(sp => sp.GetRequiredService<ISolicitorService>())
|
||||||
|
.AddScoped<IReadOnlyCitiesService, ReadOnlyCitiesService>()
|
||||||
|
.AddScoped<IRatingsProviderService, RatingsProviderService>()
|
||||||
|
.AddScoped<ISolicitorComparerFactory, SolicitorComparerFactory>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.Core.Data;
|
||||||
|
|
||||||
|
public interface IRatingsProviderRepository
|
||||||
|
{
|
||||||
|
IAsyncEnumerable<string> GetAllRatingsProvidersAsync();
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
using Solicitors.Core.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.Core.Data;
|
||||||
|
|
||||||
|
public interface IReadOnlySolicitorRepository
|
||||||
|
{
|
||||||
|
IAsyncEnumerable<Solicitor> GetAllSolicitorsAsync();
|
||||||
|
Task<Solicitor?> GetSolicitorByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IReadOnlyCitiesRepository
|
||||||
|
{
|
||||||
|
IAsyncEnumerable<string> GetCitiesAsync();
|
||||||
|
}
|
||||||
10
backend/libs/Solicitors.Core/Data/ISolicitorRepository.cs
Normal file
10
backend/libs/Solicitors.Core/Data/ISolicitorRepository.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using Solicitors.Core.Models.Imports;
|
||||||
|
|
||||||
|
namespace Solicitors.Core.Data;
|
||||||
|
|
||||||
|
public interface ISolicitorRepository : IReadOnlySolicitorRepository
|
||||||
|
{
|
||||||
|
Task EnsureCreatedAsync(CancellationToken cancellationToken);
|
||||||
|
Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken);
|
||||||
|
Task RemoveStaleEntriesAsync(TimeSpan staleAge, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
6
backend/libs/Solicitors.Core/IRatingsProviderService.cs
Normal file
6
backend/libs/Solicitors.Core/IRatingsProviderService.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
public interface IRatingsProviderService
|
||||||
|
{
|
||||||
|
Task<string[]> GetRatingsProvidersAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
6
backend/libs/Solicitors.Core/IReadOnlyCitiesService.cs
Normal file
6
backend/libs/Solicitors.Core/IReadOnlyCitiesService.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
public interface IReadOnlyCitiesService
|
||||||
|
{
|
||||||
|
Task<string[]> GetAllCitiesAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
17
backend/libs/Solicitors.Core/IReadOnlySolicitorService.cs
Normal file
17
backend/libs/Solicitors.Core/IReadOnlySolicitorService.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using Solicitors.Core.Misc;
|
||||||
|
using Solicitors.Core.Models;
|
||||||
|
using Solicitors.Core.Models.View;
|
||||||
|
|
||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
public interface IReadOnlySolicitorService
|
||||||
|
{
|
||||||
|
Task<PaginationResponse<SolicitorSummary>> GetSolicitorSummariesAsync(
|
||||||
|
Pagination pagination,
|
||||||
|
string ratingsProvider,
|
||||||
|
IFilter<Solicitor>? filter = null,
|
||||||
|
IComparer<Solicitor>? ordering = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<SolicitorInfo?> GetSolicitorInfoByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
3
backend/libs/Solicitors.Core/ISolicitorService.cs
Normal file
3
backend/libs/Solicitors.Core/ISolicitorService.cs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
public interface ISolicitorService : IReadOnlySolicitorService;
|
||||||
21
backend/libs/Solicitors.Core/Misc/EnumerableExtensions.cs
Normal file
21
backend/libs/Solicitors.Core/Misc/EnumerableExtensions.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
namespace Solicitors.Core.Misc;
|
||||||
|
|
||||||
|
public static class EnumerableExtensions
|
||||||
|
{
|
||||||
|
public static IEnumerable<T[]> Batch<T>(this IEnumerable<T> source, int batchSize)
|
||||||
|
{
|
||||||
|
var batch = new List<T>(batchSize);
|
||||||
|
foreach (var item in source)
|
||||||
|
{
|
||||||
|
batch.Add(item);
|
||||||
|
if (batch.Count == batch.Capacity)
|
||||||
|
{
|
||||||
|
yield return batch.ToArray();
|
||||||
|
batch.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batch.Count > 0)
|
||||||
|
yield return batch.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/libs/Solicitors.Core/Misc/IFilter.cs
Normal file
19
backend/libs/Solicitors.Core/Misc/IFilter.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
namespace Solicitors.Core.Misc;
|
||||||
|
|
||||||
|
public interface IFilter<in T>
|
||||||
|
{
|
||||||
|
bool Filter(T item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WrapperFilter<T>(
|
||||||
|
IFilter<T>? inner,
|
||||||
|
Func<T, bool> filter) : IFilter<T>
|
||||||
|
{
|
||||||
|
public bool Filter(T item)
|
||||||
|
{
|
||||||
|
if (inner is not null)
|
||||||
|
return inner.Filter(item) && filter(item);
|
||||||
|
|
||||||
|
return filter(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/libs/Solicitors.Core/Misc/Pagination.cs
Normal file
7
backend/libs/Solicitors.Core/Misc/Pagination.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace Solicitors.Core.Misc;
|
||||||
|
|
||||||
|
public readonly struct Pagination(uint currentPage, uint pageSize)
|
||||||
|
{
|
||||||
|
public uint CurrentPage { get; } = currentPage;
|
||||||
|
public uint PageSize { get; } = pageSize;
|
||||||
|
}
|
||||||
10
backend/libs/Solicitors.Core/Models/City.cs
Normal file
10
backend/libs/Solicitors.Core/Models/City.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record City
|
||||||
|
{
|
||||||
|
public Guid CityId { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
|
||||||
|
public List<Solicitor> Solicitors { get; } = [];
|
||||||
|
}
|
||||||
8
backend/libs/Solicitors.Core/Models/Imports/Location.cs
Normal file
8
backend/libs/Solicitors.Core/Models/Imports/Location.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace Solicitors.Core.Models.Imports;
|
||||||
|
|
||||||
|
public record Location(string Address, string Phone, Rating[] LocationRatings)
|
||||||
|
{
|
||||||
|
public string Address { get; } = Address;
|
||||||
|
public string Phone { get; } = Phone;
|
||||||
|
public Rating[] LocationRatings { get; } = LocationRatings;
|
||||||
|
}
|
||||||
9
backend/libs/Solicitors.Core/Models/Imports/Rating.cs
Normal file
9
backend/libs/Solicitors.Core/Models/Imports/Rating.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace Solicitors.Core.Models.Imports;
|
||||||
|
|
||||||
|
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; } = RatingProvider;
|
||||||
|
public string RatingProviderImgSrc { get; } = RatingProviderImgSrc;
|
||||||
|
}
|
||||||
16
backend/libs/Solicitors.Core/Models/Imports/SolicitorData.cs
Normal file
16
backend/libs/Solicitors.Core/Models/Imports/SolicitorData.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
namespace Solicitors.Core.Models.Imports;
|
||||||
|
|
||||||
|
public class SolicitorData
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string UrlPath { get; init; }
|
||||||
|
|
||||||
|
public string? ShortDescription { get; init; }
|
||||||
|
public string? Phone { get; init; }
|
||||||
|
public string? Email { get; init; }
|
||||||
|
public string? Website { get; init; }
|
||||||
|
|
||||||
|
public string[] Cities { get; init; } = [];
|
||||||
|
public Rating[] Ratings { get; init; } = [];
|
||||||
|
public Location[] Offices { get; init; } = [];
|
||||||
|
}
|
||||||
19
backend/libs/Solicitors.Core/Models/Solicitor.cs
Normal file
19
backend/libs/Solicitors.Core/Models/Solicitor.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record Solicitor
|
||||||
|
{
|
||||||
|
public Guid SolicitorId { get; set; }
|
||||||
|
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public required string RelativeUrl { get; set; }
|
||||||
|
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Website { get; set; }
|
||||||
|
public string? ShortDescription { get; set; }
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
|
||||||
|
public List<City> Cities { get; } = [];
|
||||||
|
public List<SolicitorRating> Ratings { get; } = [];
|
||||||
|
public List<SolicitorLocation> Locations { get; } = [];
|
||||||
|
}
|
||||||
40
backend/libs/Solicitors.Core/Models/SolicitorInfo.cs
Normal file
40
backend/libs/Solicitors.Core/Models/SolicitorInfo.cs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
using Solicitors.Core.Models.View;
|
||||||
|
|
||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record SolicitorInfo
|
||||||
|
{
|
||||||
|
internal SolicitorInfo(Solicitor solicitor)
|
||||||
|
{
|
||||||
|
Id = solicitor.SolicitorId;
|
||||||
|
Name = solicitor.Name;
|
||||||
|
ShortDescription = solicitor.ShortDescription;
|
||||||
|
Phone = solicitor.Phone;
|
||||||
|
Email = solicitor.Email;
|
||||||
|
Website = solicitor.Website;
|
||||||
|
|
||||||
|
Cities = solicitor.Cities
|
||||||
|
.Select(city => city.Name)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Ratings = solicitor.Ratings
|
||||||
|
.Select(rating => new RatingInfo(rating))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Locations = solicitor.Locations
|
||||||
|
.Select(location => new LocationInfo(location))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string? ShortDescription { get; set; }
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Website { get; set; }
|
||||||
|
|
||||||
|
public string[] Cities { get; set; }
|
||||||
|
public RatingInfo[] Ratings { get; set; }
|
||||||
|
public LocationInfo[] Locations { get; set; }
|
||||||
|
}
|
||||||
22
backend/libs/Solicitors.Core/Models/SolicitorLocation.cs
Normal file
22
backend/libs/Solicitors.Core/Models/SolicitorLocation.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record SolicitorLocation
|
||||||
|
{
|
||||||
|
public Guid SolicitorLocationId { get; set; }
|
||||||
|
|
||||||
|
public required string Address { get; set; }
|
||||||
|
public required string Phone { get; set; }
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
|
||||||
|
public List<SolicitorLocationRating> LocationRatings { get; } = [];
|
||||||
|
|
||||||
|
public Guid SolicitorId { get; set; }
|
||||||
|
public required Solicitor Solicitor { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IRating
|
||||||
|
{
|
||||||
|
decimal Value { get; }
|
||||||
|
decimal Maximum { get; }
|
||||||
|
string Provider { get; }
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record SolicitorLocationRating : IRating
|
||||||
|
{
|
||||||
|
public Guid SolicitorLocationRatingId { get; set; }
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
14
backend/libs/Solicitors.Core/Models/SolicitorRating.cs
Normal file
14
backend/libs/Solicitors.Core/Models/SolicitorRating.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
namespace Solicitors.Core.Models;
|
||||||
|
|
||||||
|
public record SolicitorRating : IRating
|
||||||
|
{
|
||||||
|
public Guid SolicitorRatingId { get; set; }
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
18
backend/libs/Solicitors.Core/Models/View/LocationInfo.cs
Normal file
18
backend/libs/Solicitors.Core/Models/View/LocationInfo.cs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
namespace Solicitors.Core.Models.View;
|
||||||
|
|
||||||
|
public record LocationInfo
|
||||||
|
{
|
||||||
|
public LocationInfo(SolicitorLocation location)
|
||||||
|
{
|
||||||
|
Address = location.Address;
|
||||||
|
Phone = location.Phone;
|
||||||
|
Ratings = location.LocationRatings
|
||||||
|
.Select(rating => new RatingInfo(rating))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Address { get; }
|
||||||
|
public string Phone { get; }
|
||||||
|
|
||||||
|
public RatingInfo[] Ratings { get; }
|
||||||
|
}
|
||||||
15
backend/libs/Solicitors.Core/Models/View/RatingInfo.cs
Normal file
15
backend/libs/Solicitors.Core/Models/View/RatingInfo.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
namespace Solicitors.Core.Models.View;
|
||||||
|
|
||||||
|
public record RatingInfo
|
||||||
|
{
|
||||||
|
public RatingInfo(IRating rating)
|
||||||
|
{
|
||||||
|
Value = rating.Value;
|
||||||
|
Maximum = rating.Maximum;
|
||||||
|
Provider = rating.Provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public decimal Value { get; }
|
||||||
|
public decimal Maximum { get; }
|
||||||
|
public string Provider { get; }
|
||||||
|
}
|
||||||
19
backend/libs/Solicitors.Core/Models/View/SolicitorSummary.cs
Normal file
19
backend/libs/Solicitors.Core/Models/View/SolicitorSummary.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
namespace Solicitors.Core.Models.View;
|
||||||
|
|
||||||
|
public record SolicitorSummary
|
||||||
|
{
|
||||||
|
internal SolicitorSummary(Solicitor solicitor, string ratingsProvider)
|
||||||
|
{
|
||||||
|
Id = solicitor.SolicitorId;
|
||||||
|
Name = solicitor.Name;
|
||||||
|
ShortDescription = solicitor.ShortDescription;
|
||||||
|
var rating = solicitor.Ratings.FirstOrDefault(x => x.Provider == ratingsProvider);
|
||||||
|
if (rating is not null)
|
||||||
|
Rating = new RatingInfo(rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid Id { get; }
|
||||||
|
public string Name { get; }
|
||||||
|
public string? ShortDescription { get; }
|
||||||
|
public RatingInfo? Rating { get; }
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
using Solicitors.Core.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.Core.Ordering;
|
||||||
|
|
||||||
|
internal class DefaultSolicitorDataOrdering : IComparer<Solicitor>
|
||||||
|
{
|
||||||
|
public static IComparer<Solicitor> Instance { get; } = new DefaultSolicitorDataOrdering();
|
||||||
|
|
||||||
|
private DefaultSolicitorDataOrdering()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Compare(Solicitor? x, Solicitor? y)
|
||||||
|
{
|
||||||
|
if (x is null)
|
||||||
|
return y is null ? 0 : int.MaxValue;
|
||||||
|
|
||||||
|
else if (y is null)
|
||||||
|
return int.MinValue;
|
||||||
|
|
||||||
|
return Comparer<Guid>.Default.Compare(x.SolicitorId, y.SolicitorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
using Solicitors.Core.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.Core.Ordering;
|
||||||
|
|
||||||
|
public interface ISolicitorComparerFactory
|
||||||
|
{
|
||||||
|
IComparer<Solicitor> GetComparer(
|
||||||
|
OrderingType? orderingType,
|
||||||
|
string ratingsProvider);
|
||||||
|
}
|
||||||
26
backend/libs/Solicitors.Core/Ordering/NameComparer.cs
Normal file
26
backend/libs/Solicitors.Core/Ordering/NameComparer.cs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
using Solicitors.Core.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.Core.Ordering;
|
||||||
|
|
||||||
|
internal class NameComparer(bool ascending) : IComparer<Solicitor>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/libs/Solicitors.Core/Ordering/OrderingType.cs
Normal file
9
backend/libs/Solicitors.Core/Ordering/OrderingType.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace Solicitors.Core.Ordering;
|
||||||
|
|
||||||
|
public enum OrderingType
|
||||||
|
{
|
||||||
|
RatingDescending,
|
||||||
|
RatingAscending,
|
||||||
|
AlphabetAscending,
|
||||||
|
AlphabetDescending
|
||||||
|
}
|
||||||
32
backend/libs/Solicitors.Core/Ordering/RatingComparer.cs
Normal file
32
backend/libs/Solicitors.Core/Ordering/RatingComparer.cs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
using Solicitors.Core.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.Core.Ordering;
|
||||||
|
|
||||||
|
internal class RatingComparer(string ratingsProvider, bool ascending) : IComparer<Solicitor>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
using Solicitors.Core.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.Core.Ordering;
|
||||||
|
|
||||||
|
internal class SolicitorComparerFactory : ISolicitorComparerFactory
|
||||||
|
{
|
||||||
|
public IComparer<Solicitor> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/libs/Solicitors.Core/PaginationResponse.cs
Normal file
7
backend/libs/Solicitors.Core/PaginationResponse.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
public record PaginationResponse<T>(int Total, T[] Data)
|
||||||
|
{
|
||||||
|
public T[] Data { get; set; } = Data;
|
||||||
|
public int Total { get; set; } = Total;
|
||||||
|
}
|
||||||
11
backend/libs/Solicitors.Core/RatingsProviderService.cs
Normal file
11
backend/libs/Solicitors.Core/RatingsProviderService.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using Solicitors.Core.Data;
|
||||||
|
|
||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
internal class RatingsProviderService(IRatingsProviderRepository repo) : IRatingsProviderService
|
||||||
|
{
|
||||||
|
public async Task<string[]> GetRatingsProvidersAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await repo.GetAllRatingsProvidersAsync().ToArrayAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/libs/Solicitors.Core/ReadOnlyCitiesService.cs
Normal file
12
backend/libs/Solicitors.Core/ReadOnlyCitiesService.cs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
using Solicitors.Core.Data;
|
||||||
|
|
||||||
|
namespace Solicitors.Core;
|
||||||
|
|
||||||
|
internal class ReadOnlyCitiesService(IReadOnlyCitiesRepository repo) : IReadOnlyCitiesService
|
||||||
|
{
|
||||||
|
public async Task<string[]> GetAllCitiesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cities = repo.GetCitiesAsync();
|
||||||
|
return await cities.ToArrayAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
backend/libs/Solicitors.Core/SolicitorService.cs
Normal file
44
backend/libs/Solicitors.Core/SolicitorService.cs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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(IReadOnlySolicitorRepository repo) : ISolicitorService
|
||||||
|
{
|
||||||
|
public async Task<PaginationResponse<SolicitorSummary>> GetSolicitorSummariesAsync(
|
||||||
|
Pagination pagination,
|
||||||
|
string ratingsProvider,
|
||||||
|
IFilter<Solicitor>? filter = null,
|
||||||
|
IComparer<Solicitor>? ordering = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var allSolicitors = repo.GetAllSolicitorsAsync();
|
||||||
|
if (filter is not null)
|
||||||
|
allSolicitors = allSolicitors.Where(filter.Filter);
|
||||||
|
|
||||||
|
var matchingSolicitors = await allSolicitors.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
ordering ??= DefaultSolicitorDataOrdering.Instance;
|
||||||
|
|
||||||
|
var results = matchingSolicitors
|
||||||
|
.OrderBy(x => x, ordering)
|
||||||
|
.Skip((int)((pagination.CurrentPage - 1) * pagination.PageSize))
|
||||||
|
.Take((int)pagination.PageSize)
|
||||||
|
.Select(x => new SolicitorSummary(x, ratingsProvider))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new PaginationResponse<SolicitorSummary>(matchingSolicitors.Length, results);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SolicitorInfo?> GetSolicitorInfoByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var data = await repo.GetSolicitorByIdAsync(id, cancellationToken);
|
||||||
|
if (data is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new SolicitorInfo(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/libs/Solicitors.Core/Solicitors.Core.csproj
Normal file
14
backend/libs/Solicitors.Core/Solicitors.Core.csproj
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Solicitors.Core.Tests" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
48
backend/libs/Solicitors.Data/DIExtensions.cs
Normal file
48
backend/libs/Solicitors.Data/DIExtensions.cs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Solicitors.Core.Data;
|
||||||
|
using Solicitors.Data.RepositorySetup;
|
||||||
|
using Solicitors.Data.RepositorySetup.InMemory;
|
||||||
|
using Solicitors.Data.RepositorySetup.Sqlite;
|
||||||
|
|
||||||
|
namespace Solicitors.Data;
|
||||||
|
|
||||||
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
|
public static class DIExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddData(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
return services
|
||||||
|
.AddScoped<SolicitorsRepository>()
|
||||||
|
.AddScoped<ISolicitorRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
|
||||||
|
.AddScoped<IReadOnlySolicitorRepository>(sp => sp.GetRequiredService<ISolicitorRepository>())
|
||||||
|
.AddScoped<IReadOnlyCitiesRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
|
||||||
|
.AddScoped<IRatingsProviderRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
|
||||||
|
.AddSingleton(GetSetupService(configuration));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IRepoSetupService GetSetupService(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var options = ReadConfigForOptions(configuration);
|
||||||
|
|
||||||
|
return options switch
|
||||||
|
{
|
||||||
|
InMemoryDbSetupOptions inMemOptions => new InMemorySetupService(inMemOptions),
|
||||||
|
SqliteDbSetupOptions sqliteOptions => new SqliteSetupService(sqliteOptions),
|
||||||
|
_ => throw new ArgumentException("Options type not supported", nameof(configuration))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IRepoSetupOptions? ReadConfigForOptions(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var dbConfig = configuration.GetSection("Database");
|
||||||
|
var dbType = dbConfig["Type"]?.ToLower();
|
||||||
|
return dbType switch
|
||||||
|
{
|
||||||
|
"inmemory" => dbConfig.Get<InMemoryDbSetupOptions>(),
|
||||||
|
"sqlite" => dbConfig.Get<SqliteDbSetupOptions>(),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
namespace Solicitors.Data.RepositorySetup;
|
||||||
|
|
||||||
|
public interface IRepoSetupOptions;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Solicitors.Data.RepositorySetup;
|
||||||
|
|
||||||
|
internal interface IRepoSetupService
|
||||||
|
{
|
||||||
|
void OnConfiguring(DbContextOptionsBuilder optionsBuilder);
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.Data.RepositorySetup.InMemory;
|
||||||
|
|
||||||
|
public class InMemoryDbSetupOptions(string dbName) : IRepoSetupOptions
|
||||||
|
{
|
||||||
|
public string DbName { get; } = dbName;
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Solicitors.Data.RepositorySetup.InMemory;
|
||||||
|
|
||||||
|
internal class InMemorySetupService(InMemoryDbSetupOptions config) : IRepoSetupService
|
||||||
|
{
|
||||||
|
public void OnConfiguring(DbContextOptionsBuilder options)
|
||||||
|
{
|
||||||
|
options.UseInMemoryDatabase(config.DbName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.Data.RepositorySetup.Sqlite;
|
||||||
|
|
||||||
|
public class SqliteDbSetupOptions(string dbPath) : IRepoSetupOptions
|
||||||
|
{
|
||||||
|
public string DbPath { get; } = dbPath;
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Solicitors.Data.RepositorySetup.Sqlite;
|
||||||
|
|
||||||
|
internal class SqliteSetupService(SqliteDbSetupOptions config) : IRepoSetupService
|
||||||
|
{
|
||||||
|
public void OnConfiguring(DbContextOptionsBuilder options)
|
||||||
|
{
|
||||||
|
options.UseSqlite($"Data Source={config.DbPath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/libs/Solicitors.Data/Solicitors.Data.csproj
Normal file
21
backend/libs/Solicitors.Data/Solicitors.Data.csproj
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Solicitors.Core\Solicitors.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
335
backend/libs/Solicitors.Data/SolicitorsRepository.cs
Normal file
335
backend/libs/Solicitors.Data/SolicitorsRepository.cs
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Solicitors.Core.Data;
|
||||||
|
using Solicitors.Core.Models;
|
||||||
|
using Solicitors.Core.Models.Imports;
|
||||||
|
using Solicitors.Data.RepositorySetup;
|
||||||
|
|
||||||
|
namespace Solicitors.Data;
|
||||||
|
|
||||||
|
internal class SolicitorsRepository(IRepoSetupService setup)
|
||||||
|
: DbContext, ISolicitorRepository, IReadOnlyCitiesRepository, IRatingsProviderRepository
|
||||||
|
{
|
||||||
|
public DbSet<Solicitor> Solicitors { get; set; }
|
||||||
|
public DbSet<SolicitorRating> SolicitorRatings { get; set; }
|
||||||
|
public DbSet<SolicitorLocation> SolicitorLocations { get; set; }
|
||||||
|
public DbSet<SolicitorLocationRating> SolicitorLocationRatings { get; set; }
|
||||||
|
public DbSet<City> Cities { get; set; }
|
||||||
|
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
=> setup.OnConfiguring(optionsBuilder);
|
||||||
|
|
||||||
|
public Task EnsureCreatedAsync(CancellationToken cancellationToken)
|
||||||
|
=> Database.EnsureCreatedAsync(cancellationToken);
|
||||||
|
|
||||||
|
public IAsyncEnumerable<Solicitor> GetAllSolicitorsAsync()
|
||||||
|
=> Solicitors
|
||||||
|
.Include(solicitor => solicitor.Cities)
|
||||||
|
.Include(solicitor => solicitor.Locations)
|
||||||
|
.ThenInclude(location => location.LocationRatings)
|
||||||
|
.Include(solicitor => solicitor.Ratings)
|
||||||
|
.AsAsyncEnumerable();
|
||||||
|
|
||||||
|
public Task<Solicitor?> GetSolicitorByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
|
=> Solicitors
|
||||||
|
.Include(solicitor => solicitor.Cities)
|
||||||
|
.Include(solicitor => solicitor.Locations)
|
||||||
|
.ThenInclude(location => location.LocationRatings)
|
||||||
|
.Include(solicitor => solicitor.Ratings)
|
||||||
|
.FirstOrDefaultAsync(x => x.SolicitorId == id, cancellationToken);
|
||||||
|
|
||||||
|
public IAsyncEnumerable<string> GetCitiesAsync()
|
||||||
|
{
|
||||||
|
return Cities
|
||||||
|
.Select(city => city.Name)
|
||||||
|
.AsAsyncEnumerable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<string> GetAllRatingsProvidersAsync()
|
||||||
|
{
|
||||||
|
return SolicitorRatings
|
||||||
|
.Select(rating => rating.Provider)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(x => x == "Solicitors.com" ? 0 : 1)
|
||||||
|
.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 AddNewSolicitorAsync(solicitor, cancellationToken);
|
||||||
|
else
|
||||||
|
await UpdateExistingSolicitorAsync(solicitor, matchingSolicitor, cancellationToken);
|
||||||
|
|
||||||
|
await SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 AddNewSolicitorAsync(
|
||||||
|
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 Task UpdateExistingSolicitorAsync(
|
||||||
|
SolicitorData solicitor,
|
||||||
|
Solicitor matchingSolicitor,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
UpdateSolicitorRootData(matchingSolicitor, solicitor);
|
||||||
|
var citiesUpdate = UpdateExistingCitiesAsync(solicitor, matchingSolicitor, cancellationToken);
|
||||||
|
UpdateExistingRatings(solicitor, matchingSolicitor);
|
||||||
|
UpdateExistingLocations(solicitor, matchingSolicitor);
|
||||||
|
return citiesUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateExistingCitiesAsync(
|
||||||
|
SolicitorData solicitor,
|
||||||
|
Solicitor matchingSolicitor,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
matchingCity ??= await GetCityByNameAsync(cityName, cancellationToken);
|
||||||
|
|
||||||
|
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<City?> GetCityByNameAsync(string cityName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Cities
|
||||||
|
.Include(city => city.Solicitors)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
city => city.Name == cityName,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/libs/Solicitors.HtmlParsing/DIExtensions.cs
Normal file
13
backend/libs/Solicitors.HtmlParsing/DIExtensions.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
return services.AddSingleton<IHtmlParser, HtmlParser>();
|
||||||
|
}
|
||||||
|
}
|
||||||
130
backend/libs/Solicitors.HtmlParsing/HtmlParser.cs
Normal file
130
backend/libs/Solicitors.HtmlParsing/HtmlParser.cs
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
namespace Solicitors.HtmlParsing;
|
||||||
|
|
||||||
|
using Models;
|
||||||
|
|
||||||
|
internal class HtmlParser : IHtmlParser
|
||||||
|
{
|
||||||
|
private readonly string[] _voidElements =
|
||||||
|
[
|
||||||
|
"area",
|
||||||
|
"base",
|
||||||
|
"br",
|
||||||
|
"col",
|
||||||
|
"embed",
|
||||||
|
"hr",
|
||||||
|
"img",
|
||||||
|
"input",
|
||||||
|
"link",
|
||||||
|
"meta",
|
||||||
|
"source",
|
||||||
|
"track",
|
||||||
|
"wbr"
|
||||||
|
];
|
||||||
|
|
||||||
|
public IEnumerable<IHtmlNode> ParseHtml(string html)
|
||||||
|
{
|
||||||
|
// wanted to do this in one pass but was quite time-consuming
|
||||||
|
// so taking a little lesson from compiler development
|
||||||
|
// and lex the text into tokens before parsing :)
|
||||||
|
// deferred execution might help it still be "one pass"?
|
||||||
|
var stringEnumerator = html.GetEnumerator();
|
||||||
|
var tokens = LexHtml(stringEnumerator);
|
||||||
|
var tokenEnumerator = tokens.GetEnumerator();
|
||||||
|
return ParseHtml(tokenEnumerator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> LexHtml(CharEnumerator content)
|
||||||
|
{
|
||||||
|
var work = "";
|
||||||
|
while (content.MoveNext())
|
||||||
|
{
|
||||||
|
if (content.Current == '<' && !string.IsNullOrWhiteSpace(work))
|
||||||
|
{
|
||||||
|
yield return work.Trim();
|
||||||
|
work = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
work += content.Current;
|
||||||
|
|
||||||
|
if (content.Current == '>' && !string.IsNullOrWhiteSpace(work))
|
||||||
|
{
|
||||||
|
yield return work.Trim();
|
||||||
|
work = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<IHtmlNode> ParseHtml(IEnumerator<string> tokens)
|
||||||
|
{
|
||||||
|
while (tokens.MoveNext())
|
||||||
|
{
|
||||||
|
var token = tokens.Current;
|
||||||
|
if (!token.StartsWith('<'))
|
||||||
|
{
|
||||||
|
yield return new StringNode(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var noBraces = token.Substring(1, token.Length - 2);
|
||||||
|
var split = noBraces.Split(' ');
|
||||||
|
IHtmlAttribute[] attributes = [];
|
||||||
|
if (split.Length > 1)
|
||||||
|
{
|
||||||
|
attributes = ParseAttributes(noBraces).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsVoidOrSelfClosingElement(token))
|
||||||
|
yield return new HtmlNode(split.First(), attributes);
|
||||||
|
else if (token.StartsWith("</"))
|
||||||
|
yield break;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var children = ParseHtml(tokens).ToArray();
|
||||||
|
yield return new ParentHtmlNode(split.First(), attributes, children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsVoidOrSelfClosingElement(string token)
|
||||||
|
{
|
||||||
|
if (token.StartsWith("<!") || token.EndsWith("/>"))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var tagName = token.Split(' ').First()[1..];
|
||||||
|
if (tagName.EndsWith('>'))
|
||||||
|
tagName = tagName[..^1];
|
||||||
|
return _voidElements.Contains(tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<IHtmlAttribute> ParseAttributes(string tokenNoBraces)
|
||||||
|
{
|
||||||
|
var attributesString = string.Join(' ', tokenNoBraces.Split(' ').Skip(1));
|
||||||
|
var attString = "";
|
||||||
|
var inQuote = false;
|
||||||
|
foreach (var c in attributesString)
|
||||||
|
{
|
||||||
|
if (!inQuote && c == ' ')
|
||||||
|
{
|
||||||
|
yield return ParseAttribute(attString);
|
||||||
|
attString = "";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
attString += c;
|
||||||
|
if (c == '\"')
|
||||||
|
inQuote = !inQuote;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return ParseAttribute(attString);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IHtmlAttribute ParseAttribute(string attString)
|
||||||
|
{
|
||||||
|
var split = attString.Split('=');
|
||||||
|
if (split.Length > 1)
|
||||||
|
return new HtmlValueAttribute(split[0], string.Join('=', split.Skip(1)));
|
||||||
|
else
|
||||||
|
return new HtmlAttribute(attString);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/libs/Solicitors.HtmlParsing/IHtmlParser.cs
Normal file
8
backend/libs/Solicitors.HtmlParsing/IHtmlParser.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace Solicitors.HtmlParsing;
|
||||||
|
|
||||||
|
using Models;
|
||||||
|
|
||||||
|
public interface IHtmlParser
|
||||||
|
{
|
||||||
|
IEnumerable<IHtmlNode> ParseHtml(string html);
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
internal class HtmlAttribute(string name) : IHtmlAttribute
|
||||||
|
{
|
||||||
|
public string Name { get; } = name;
|
||||||
|
}
|
||||||
92
backend/libs/Solicitors.HtmlParsing/Models/HtmlNode.cs
Normal file
92
backend/libs/Solicitors.HtmlParsing/Models/HtmlNode.cs
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
internal class HtmlNode(string name, IHtmlAttribute[] attributes)
|
||||||
|
: IHtmlNode
|
||||||
|
{
|
||||||
|
public virtual bool TryGetByTagName(
|
||||||
|
string tagName,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
if (tagName == name)
|
||||||
|
node = this;
|
||||||
|
|
||||||
|
return node is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] Classes
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var classAttribute = attributes
|
||||||
|
.Where(a => a is HtmlValueAttribute)
|
||||||
|
.Select(a => (a as HtmlValueAttribute)!)
|
||||||
|
.FirstOrDefault(a => a.Name.Equals("class", StringComparison.CurrentCultureIgnoreCase));
|
||||||
|
|
||||||
|
return classAttribute is not null
|
||||||
|
? classAttribute.Value.Substring(1, classAttribute.Value.Length - 2).Split(' ')
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual bool TryGetByClass(
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
|
||||||
|
if (Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
|
||||||
|
node = this;
|
||||||
|
|
||||||
|
return node is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual bool TryGetByTagNameAndClass(
|
||||||
|
string tagName,
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
if (tagName == name && Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
|
||||||
|
node = this;
|
||||||
|
|
||||||
|
return node is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual bool TryGetText([NotNullWhen(true)] out string? text)
|
||||||
|
{
|
||||||
|
text = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
|
||||||
|
{
|
||||||
|
children = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasAttribute(string attributeName)
|
||||||
|
{
|
||||||
|
return attributes
|
||||||
|
.Any(a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetAttributeValue(string attributeName, [NotNullWhen(true)] out string? attributeValue)
|
||||||
|
{
|
||||||
|
attributeValue = null;
|
||||||
|
|
||||||
|
var matchingAttribute = attributes.FirstOrDefault(
|
||||||
|
a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingAttribute is HtmlValueAttribute valueAttribute)
|
||||||
|
attributeValue = valueAttribute.Value;
|
||||||
|
|
||||||
|
return attributeValue is not null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
internal class HtmlValueAttribute(string name, string value) : HtmlAttribute(name)
|
||||||
|
{
|
||||||
|
public string Value { get; } = value;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
internal interface IHtmlAttribute
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
}
|
||||||
64
backend/libs/Solicitors.HtmlParsing/Models/IHtmlNode.cs
Normal file
64
backend/libs/Solicitors.HtmlParsing/Models/IHtmlNode.cs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
public interface IHtmlNode
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Searches this node and its direct children for a matching tag name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tagName">The tag name to match.</param>
|
||||||
|
/// <param name="node">If found, the node that matches the tag name.</param>
|
||||||
|
/// <param name="noChildren">If true, only check this node, not its children.</param>
|
||||||
|
/// <returns>True if this node, or any of its direct children, have a matching tag name; false otherwise.</returns>
|
||||||
|
bool TryGetByTagName(
|
||||||
|
string tagName,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches this node and its direct children for a matching class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="className">The class name to match.</param>
|
||||||
|
/// <param name="node">If found, the node that matches the class name.</param>
|
||||||
|
/// <param name="noChildren">If true, only check this node, not its children.</param>
|
||||||
|
/// <returns>True if this node, or any of its direct children, have a matching class name; false otherwise.</returns>
|
||||||
|
bool TryGetByClass(
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches this node and its direct children for a matching tag name and matching class on the same node.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tagName">The tag name to match.</param>
|
||||||
|
/// <param name="className">The class name to match.</param>
|
||||||
|
/// <param name="node">If found, the node that matches the tag name and class name.</param>
|
||||||
|
/// <param name="noChildren">If true, only check this node, not its children.</param>
|
||||||
|
/// <returns>True if this node, or any of its direct children, have a matching tag name and class name; false otherwise.</returns>
|
||||||
|
bool TryGetByTagNameAndClass(
|
||||||
|
string tagName,
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks this node (and *not* its children) to see if it is text content.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">If found, the text content.</param>
|
||||||
|
/// <returns>True if the node is a text node, false otherwise.</returns>
|
||||||
|
bool TryGetText([NotNullWhen(true)] out string? text);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks this node to see if it has child elements.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="children">If found, the child elements.</param>
|
||||||
|
/// <returns>True if the node has child elements, false otherwise.</returns>
|
||||||
|
bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children);
|
||||||
|
|
||||||
|
bool HasAttribute(string attributeName);
|
||||||
|
|
||||||
|
bool TryGetAttributeValue(
|
||||||
|
string attributeName,
|
||||||
|
[NotNullWhen(true)] out string? attributeValue);
|
||||||
|
}
|
||||||
92
backend/libs/Solicitors.HtmlParsing/Models/ParentHtmlNode.cs
Normal file
92
backend/libs/Solicitors.HtmlParsing/Models/ParentHtmlNode.cs
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtmlNode[] children)
|
||||||
|
: HtmlNode(tagName, attributes)
|
||||||
|
{
|
||||||
|
public override bool TryGetByTagName(
|
||||||
|
string tagName,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
if (!base.TryGetByTagName(tagName, out node, noChildren) && !noChildren)
|
||||||
|
{
|
||||||
|
var nodesToCheck = new Queue<IHtmlNode>(children);
|
||||||
|
while (nodesToCheck.TryDequeue(out var child))
|
||||||
|
{
|
||||||
|
if (child.TryGetByTagName(tagName, out var childNode, true))
|
||||||
|
{
|
||||||
|
node = childNode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.TryGetChildren(out var grandChildren))
|
||||||
|
foreach (var grandChild in grandChildren)
|
||||||
|
nodesToCheck.Enqueue(grandChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool TryGetByClass(
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
if (!base.TryGetByClass(className, out node, noChildren) && !noChildren)
|
||||||
|
{
|
||||||
|
var nodesToCheck = new Queue<IHtmlNode>(children);
|
||||||
|
while (nodesToCheck.TryDequeue(out var child))
|
||||||
|
{
|
||||||
|
if (child.TryGetByClass(className, out var childNode, true))
|
||||||
|
{
|
||||||
|
node = childNode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.TryGetChildren(out var grandChildren))
|
||||||
|
foreach (var grandChild in grandChildren)
|
||||||
|
nodesToCheck.Enqueue(grandChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool TryGetByTagNameAndClass(
|
||||||
|
string tagName,
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
if (!base.TryGetByTagNameAndClass(tagName, className, out node, noChildren) && !noChildren)
|
||||||
|
{
|
||||||
|
var nodesToCheck = new Queue<IHtmlNode>(children);
|
||||||
|
while (nodesToCheck.TryDequeue(out var child))
|
||||||
|
{
|
||||||
|
if (child.TryGetByTagNameAndClass(tagName, className, out var childNode, true))
|
||||||
|
{
|
||||||
|
node = childNode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.TryGetChildren(out var grandChildren))
|
||||||
|
foreach (var grandChild in grandChildren)
|
||||||
|
nodesToCheck.Enqueue(grandChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children1)
|
||||||
|
{
|
||||||
|
children1 = children;
|
||||||
|
return children.Length != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backend/libs/Solicitors.HtmlParsing/Models/StringNode.cs
Normal file
57
backend/libs/Solicitors.HtmlParsing/Models/StringNode.cs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Solicitors.HtmlParsing.Models;
|
||||||
|
|
||||||
|
internal class StringNode(string content)
|
||||||
|
: IHtmlNode
|
||||||
|
{
|
||||||
|
public bool TryGetByTagName(
|
||||||
|
string tagName,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetByClass(
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetByTagNameAndClass(
|
||||||
|
string tagName,
|
||||||
|
string className,
|
||||||
|
[NotNullWhen(true)] out IHtmlNode? node,
|
||||||
|
bool noChildren = false)
|
||||||
|
{
|
||||||
|
node = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetText([NotNullWhen(true)] out string? text)
|
||||||
|
{
|
||||||
|
text = content;
|
||||||
|
return !string.IsNullOrEmpty(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
|
||||||
|
{
|
||||||
|
children = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasAttribute(string attributeName) => false;
|
||||||
|
|
||||||
|
public bool TryGetAttributeValue(
|
||||||
|
string attributeName,
|
||||||
|
[NotNullWhen(true)] out string? attributeValue)
|
||||||
|
{
|
||||||
|
attributeValue = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
130
backend/tests/Solicitors.Core.Tests/SolicitorServiceTests.cs
Normal file
130
backend/tests/Solicitors.Core.Tests/SolicitorServiceTests.cs
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
using Moq;
|
||||||
|
using Solicitors.Core.Data;
|
||||||
|
using Solicitors.Core.Models;
|
||||||
|
|
||||||
|
namespace Solicitors.Core.Tests;
|
||||||
|
|
||||||
|
public class SolicitorServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IReadOnlySolicitorRepository> _mockRepo;
|
||||||
|
private readonly SolicitorService _service;
|
||||||
|
|
||||||
|
public SolicitorServiceTests()
|
||||||
|
{
|
||||||
|
_mockRepo = new Mock<IReadOnlySolicitorRepository>();
|
||||||
|
_service = new SolicitorService(_mockRepo.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSolicitorInfoByIdAsync_Should_ReturnNull_When_NoMatchingSolicitor()
|
||||||
|
{
|
||||||
|
//arrange
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
_mockRepo
|
||||||
|
.Setup(x => x.GetSolicitorByIdAsync(It.IsAny<Guid>(), cts.Token))
|
||||||
|
.ReturnsAsync(default(Solicitor?));
|
||||||
|
|
||||||
|
//act
|
||||||
|
var result = await _service.GetSolicitorInfoByIdAsync(id, cts.Token);
|
||||||
|
|
||||||
|
//assert
|
||||||
|
Assert.Null(result);
|
||||||
|
_mockRepo.Verify(x => x.GetSolicitorByIdAsync(id, cts.Token), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSolicitorInfoByIdAsync_Should_ReturnMainSolicitorInfo_When_MatchingSolicitor()
|
||||||
|
{
|
||||||
|
//arrange
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
|
||||||
|
var expected = new Solicitor
|
||||||
|
{
|
||||||
|
SolicitorId = id,
|
||||||
|
Name = "Solicitor",
|
||||||
|
ShortDescription = "Short Desc",
|
||||||
|
Phone = "000 0000 0000",
|
||||||
|
Email = "example@example.com",
|
||||||
|
Website = "example.com",
|
||||||
|
RelativeUrl = "/-some-solicitor.html"
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockRepo
|
||||||
|
.Setup(x => x.GetSolicitorByIdAsync(It.IsAny<Guid>(), cts.Token))
|
||||||
|
.ReturnsAsync(expected);
|
||||||
|
|
||||||
|
//act
|
||||||
|
var result = await _service.GetSolicitorInfoByIdAsync(id, cts.Token);
|
||||||
|
|
||||||
|
//assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(result.Id, expected.SolicitorId);
|
||||||
|
Assert.Equal(result.Name, expected.Name);
|
||||||
|
Assert.Equal(result.ShortDescription, expected.ShortDescription);
|
||||||
|
Assert.Equal(result.Phone, expected.Phone);
|
||||||
|
Assert.Equal(result.Email, expected.Email);
|
||||||
|
Assert.Equal(result.Website, expected.Website);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSolicitorInfoByIdAsync_Should_MapCities_When_CitiesArePresent()
|
||||||
|
{
|
||||||
|
//arrange
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
|
||||||
|
var expected = new Solicitor
|
||||||
|
{
|
||||||
|
Name = "Solicitor",
|
||||||
|
RelativeUrl = "/-some-solicitor.html"
|
||||||
|
};
|
||||||
|
|
||||||
|
expected.Cities.Add(new City { Name = "London" });
|
||||||
|
|
||||||
|
_mockRepo
|
||||||
|
.Setup(x => x.GetSolicitorByIdAsync(It.IsAny<Guid>(), cts.Token))
|
||||||
|
.ReturnsAsync(expected);
|
||||||
|
// expected.Ratings.Add(new SolicitorRating { Provider = "Google", Value = 4, Maximum = 5, Solicitor = expected });
|
||||||
|
|
||||||
|
|
||||||
|
//act
|
||||||
|
var result = await _service.GetSolicitorInfoByIdAsync(id, cts.Token);
|
||||||
|
|
||||||
|
//assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Collection(result.Cities, city => Assert.Equal("London", city));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSolicitorInfoByIdAsync_Should_MapRatings_When_RatingsArePresent()
|
||||||
|
{
|
||||||
|
//arrange
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
|
||||||
|
var expected = new Solicitor
|
||||||
|
{
|
||||||
|
Name = "Solicitor",
|
||||||
|
RelativeUrl = "/-some-solicitor.html"
|
||||||
|
};
|
||||||
|
expected.Ratings.Add(new SolicitorRating { Provider = "Google", Value = 4, Maximum = 5, Solicitor = expected });
|
||||||
|
|
||||||
|
_mockRepo
|
||||||
|
.Setup(x => x.GetSolicitorByIdAsync(It.IsAny<Guid>(), cts.Token))
|
||||||
|
.ReturnsAsync(expected);
|
||||||
|
|
||||||
|
//act
|
||||||
|
var result = await _service.GetSolicitorInfoByIdAsync(id, cts.Token);
|
||||||
|
|
||||||
|
//assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Collection(result.Ratings, rating =>
|
||||||
|
{
|
||||||
|
Assert.Equal("Google", rating.Provider);
|
||||||
|
Assert.Equal(4, rating.Value);
|
||||||
|
Assert.Equal(5, rating.Maximum);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\libs\Solicitors.Core\Solicitors.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
BIN
frontend/.yarn/install-state.gz
Normal file
BIN
frontend/.yarn/install-state.gz
Normal file
Binary file not shown.
8
frontend/.yarnrc.yml
Normal file
8
frontend/.yarnrc.yml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
approvedGitRepositories:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
enableScripts: true
|
||||||
|
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
npmMinimalAgeGate: 0
|
||||||
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
FROM node:26.3.1-alpine3.24 as build
|
||||||
|
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
|
RUN mkdir -p /code
|
||||||
|
WORKDIR /code
|
||||||
|
ADD . /code
|
||||||
|
RUN npm install -g -s --no-progress corepack
|
||||||
|
RUN COREPACK_ENABLE_DOWNLOAD_PROMPT=0 /bin/sh -c "yarn -v"
|
||||||
|
RUN yarn install
|
||||||
|
RUN yarn run build
|
||||||
|
|
||||||
|
FROM httpd:2.4.68-alpine3.24
|
||||||
|
COPY --from=build /code/dist /usr/local/apache2/htdocs/
|
||||||
15
frontend/README.md
Normal file
15
frontend/README.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# `create-preact`
|
||||||
|
|
||||||
|
<h2 align="center">
|
||||||
|
<img height="256" width="256" src="./src/assets/preact.svg">
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<h3 align="center">Get started using Preact and Vite!</h3>
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
- `yarn dev` - Starts a dev server at http://localhost:5173/
|
||||||
|
|
||||||
|
- `yarn build` - Builds for production, emitting to `dist/`
|
||||||
|
|
||||||
|
- `yarn preview` - Starts a server at http://localhost:4173/ to test production build locally
|
||||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
|
<link href="https://fonts.bunny.net/css?family=roboto:300,400,500,700" rel="stylesheet" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<title>Vite + Preact</title>
|
||||||
|
<style>body { margin: 0; max-height: 100vh; overflow: hidden; }</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^8.0.1",
|
||||||
|
"@base-ui/react": "^1.6.0",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@fontsource/roboto": "^5.2.10",
|
||||||
|
"@mui/icons-material": "^9.1.1",
|
||||||
|
"@mui/material": "^9.1.2",
|
||||||
|
"axios": "^1.18.1",
|
||||||
|
"preact": "^10.26.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.10.2",
|
||||||
|
"eslint": "^10.5.0",
|
||||||
|
"eslint-config-preact": "^2.0.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^7.0.4"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "preact"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@4.17.0"
|
||||||
|
}
|
||||||
15
frontend/public/vite.svg
Normal file
15
frontend/public/vite.svg
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%">
|
||||||
|
<stop offset="0%" stop-color="#41D1FF"></stop>
|
||||||
|
<stop offset="100%" stop-color="#BD34FE"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%">
|
||||||
|
<stop offset="0%" stop-color="#FFEA83"></stop>
|
||||||
|
<stop offset="8.333%" stop-color="#FFDD35"></stop>
|
||||||
|
<stop offset="100%" stop-color="#FFA800"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path>
|
||||||
|
<path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
6
frontend/src/assets/preact.svg
Normal file
6
frontend/src/assets/preact.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="27.68" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 296">
|
||||||
|
<path fill="#673AB8" d="m128 0l128 73.9v147.8l-128 73.9L0 221.7V73.9z"></path>
|
||||||
|
<path fill="#FFF" d="M34.865 220.478c17.016 21.78 71.095 5.185 122.15-34.704c51.055-39.888 80.24-88.345 63.224-110.126c-17.017-21.78-71.095-5.184-122.15 34.704c-51.055 39.89-80.24 88.346-63.224 110.126Zm7.27-5.68c-5.644-7.222-3.178-21.402 7.573-39.253c11.322-18.797 30.541-39.548 54.06-57.923c23.52-18.375 48.303-32.004 69.281-38.442c19.922-6.113 34.277-5.075 39.92 2.148c5.644 7.223 3.178 21.403-7.573 39.254c-11.322 18.797-30.541 39.547-54.06 57.923c-23.52 18.375-48.304 32.004-69.281 38.441c-19.922 6.114-34.277 5.076-39.92-2.147Z"></path>
|
||||||
|
<path fill="#FFF" d="M220.239 220.478c17.017-21.78-12.169-70.237-63.224-110.126C105.96 70.464 51.88 53.868 34.865 75.648c-17.017 21.78 12.169 70.238 63.224 110.126c51.055 39.889 105.133 56.485 122.15 34.704Zm-7.27-5.68c-5.643 7.224-19.998 8.262-39.92 2.148c-20.978-6.437-45.761-20.066-69.28-38.441c-23.52-18.376-42.74-39.126-54.06-57.923c-10.752-17.851-13.218-32.03-7.575-39.254c5.644-7.223 19.999-8.261 39.92-2.148c20.978 6.438 45.762 20.067 69.281 38.442c23.52 18.375 42.739 39.126 54.06 57.923c10.752 17.85 13.218 32.03 7.574 39.254Z"></path>
|
||||||
|
<path fill="#FFF" d="M127.552 167.667c10.827 0 19.603-8.777 19.603-19.604c0-10.826-8.776-19.603-19.603-19.603c-10.827 0-19.604 8.777-19.604 19.603c0 10.827 8.777 19.604 19.604 19.604Z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
138
frontend/src/components/detail.tsx
Normal file
138
frontend/src/components/detail.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Modal from "@mui/material/Modal";
|
||||||
|
import { forwardRef, useEffect, useState } from "react";
|
||||||
|
import { SolicitorInfo } from "../models/solicitorSummary";
|
||||||
|
import getSolicitor from "../functions/getSolicitor";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import { Grid, Rating, Typography } from "@mui/material";
|
||||||
|
import capitaliseFirstLetter from "../functions/capitaliseFirstLetter";
|
||||||
|
|
||||||
|
interface ClosedProps {
|
||||||
|
open : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenProps {
|
||||||
|
open : true;
|
||||||
|
id : string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DetailInfo = OpenProps | ClosedProps;
|
||||||
|
type DetailProps = DetailInfo & { onClose : (error? : string) => void };
|
||||||
|
|
||||||
|
|
||||||
|
export default function Detail(props : DetailProps) {
|
||||||
|
const [data, setData] = useState<SolicitorInfo | 'loading'>('loading');
|
||||||
|
|
||||||
|
const handleResult = (result : SolicitorInfo | undefined) => {
|
||||||
|
if (data == 'loading' && props.open && props.id == result?.id) {
|
||||||
|
setData(result);
|
||||||
|
} else if (!result) {
|
||||||
|
handleClose('An error occurred loading the solicitor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = (error? : string) => {
|
||||||
|
props.onClose(error);
|
||||||
|
setData('loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.open && data == 'loading') {
|
||||||
|
getSolicitor(props.id)
|
||||||
|
.then(result => handleResult(result));
|
||||||
|
}
|
||||||
|
}, [props.open]);
|
||||||
|
|
||||||
|
useEffect(() => {}, [data])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={props.open}
|
||||||
|
onClose={() => handleClose()}
|
||||||
|
>
|
||||||
|
<DetailDisplay data={data} />
|
||||||
|
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: 800,
|
||||||
|
maxWidth: '60%',
|
||||||
|
maxHeight: '80%',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
overflow: 'scroll',
|
||||||
|
p: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DetailDisplay = forwardRef((props : {data : SolicitorInfo | 'loading'}) => {
|
||||||
|
if (props.data === 'loading') {
|
||||||
|
return (
|
||||||
|
<Box sx={style}>
|
||||||
|
Waiting...
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Box sx={style}>
|
||||||
|
<Typography variant='h3' component='h2'>{props.data.name}</Typography>
|
||||||
|
<Typography variant='body1'>{props.data.shortDescription}</Typography>
|
||||||
|
{props.data.ratings &&
|
||||||
|
(<>
|
||||||
|
<br />
|
||||||
|
<Typography variant='h5' component='h3'>Ratings</Typography>
|
||||||
|
<Grid container direction='row' spacing={2} sx={{justifyContent: 'flex-start', alignItems: 'stretch'}}>
|
||||||
|
{props.data.ratings.map(rating => (
|
||||||
|
<Grid size={3}>
|
||||||
|
<Box sx={{padding: '15px', height: '100%'}}>
|
||||||
|
<Typography variant='body2'>{capitaliseFirstLetter(rating.provider)}</Typography>
|
||||||
|
<Rating
|
||||||
|
readOnly
|
||||||
|
value={rating.value * 5.0 / rating.maximum}
|
||||||
|
precision={0.1}
|
||||||
|
size='small'
|
||||||
|
sx={{top: '2px'}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</>)
|
||||||
|
}
|
||||||
|
{props.data.locations &&
|
||||||
|
(<>
|
||||||
|
<br />
|
||||||
|
<Typography variant='h5' component='h3'>Offices</Typography>
|
||||||
|
<Grid container direction='row' spacing={2} sx={{justifyContent: 'flex-start', alignItems: 'stretch'}}>
|
||||||
|
{props.data.locations.map(office => (
|
||||||
|
<Grid size={3}>
|
||||||
|
<Card sx={{padding: '15px', height: '100%'}}>
|
||||||
|
{office.address.split('\n').map(line => (<Typography variant='body2'>{line}</Typography>))}
|
||||||
|
{office.phone && <><br/><Typography variant='body2'>tel: {office.phone}</Typography></>}
|
||||||
|
{office.ratings && office.ratings.map(rating =>
|
||||||
|
(<>
|
||||||
|
<Typography variant='body2'>{capitaliseFirstLetter(rating.provider)}</Typography>
|
||||||
|
<Rating
|
||||||
|
readOnly
|
||||||
|
value={rating.value * 5.0 / rating.maximum}
|
||||||
|
precision={0.1}
|
||||||
|
size='small'
|
||||||
|
sx={{top: '2px'}}
|
||||||
|
/>
|
||||||
|
</>)
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</>)
|
||||||
|
}
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
42
frontend/src/components/header.tsx
Normal file
42
frontend/src/components/header.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import DarkMode from "@mui/icons-material/DarkMode";
|
||||||
|
import LightMode from "@mui/icons-material/LightMode";
|
||||||
|
import { AppBar, IconButton, Toolbar, Tooltip, Typography } from "@mui/material";
|
||||||
|
import { Theme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
export interface ThemeWrapper {
|
||||||
|
theme : Theme,
|
||||||
|
value : 'dark' | 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
theme : ThemeWrapper;
|
||||||
|
toggleTheme : () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header(props : HeaderProps) {
|
||||||
|
return (
|
||||||
|
<AppBar position='static' sx={{}}>
|
||||||
|
<Toolbar sx={{bgcolor:'primary.main'}}>
|
||||||
|
<Typography variant="h4" component="h1" sx={{ flexGrow: 2, marginLeft: '16px' }}>
|
||||||
|
Conveyancing Search
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="Toggle theme">
|
||||||
|
<IconButton
|
||||||
|
size="large"
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
aria-label="theme toggle"
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
onClick={props.toggleTheme}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
props.theme.value === 'dark'
|
||||||
|
? <DarkMode />
|
||||||
|
: <LightMode />
|
||||||
|
}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
frontend/src/components/numberField.tsx
Normal file
114
frontend/src/components/numberField.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { NumberField as BaseNumberField } from '@base-ui/react/number-field';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import FormControl from '@mui/material/FormControl';
|
||||||
|
import FormHelperText from '@mui/material/FormHelperText';
|
||||||
|
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component is a placeholder for FormControl to correctly set the shrink label state on SSR.
|
||||||
|
*/
|
||||||
|
function SSRInitialFilled(_: BaseNumberField.Root.Props) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SSRInitialFilled.muiName = 'Input';
|
||||||
|
|
||||||
|
export default function NumberField({
|
||||||
|
id: idProp,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
size = 'medium',
|
||||||
|
...other
|
||||||
|
}: BaseNumberField.Root.Props & {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
size?: 'small' | 'medium';
|
||||||
|
error?: boolean;
|
||||||
|
}) {
|
||||||
|
let id = React.useId();
|
||||||
|
if (idProp) {
|
||||||
|
id = idProp;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<BaseNumberField.Root
|
||||||
|
{...other}
|
||||||
|
render={(props, state) => (
|
||||||
|
<FormControl
|
||||||
|
size={size}
|
||||||
|
ref={props.ref}
|
||||||
|
disabled={state.disabled}
|
||||||
|
required={state.required}
|
||||||
|
error={error}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SSRInitialFilled {...other} />
|
||||||
|
<InputLabel htmlFor={id}>{label}</InputLabel>
|
||||||
|
<BaseNumberField.Input
|
||||||
|
id={id}
|
||||||
|
render={(props, state) => (
|
||||||
|
<OutlinedInput
|
||||||
|
aria-describedby={`${id}-helper-text`}
|
||||||
|
label={label}
|
||||||
|
inputRef={props.ref}
|
||||||
|
value={state.inputValue}
|
||||||
|
onBlur={props.onBlur}
|
||||||
|
onChange={props.onChange}
|
||||||
|
onKeyUp={props.onKeyUp}
|
||||||
|
onKeyDown={props.onKeyDown}
|
||||||
|
onFocus={props.onFocus}
|
||||||
|
slotProps={{
|
||||||
|
input: props,
|
||||||
|
}}
|
||||||
|
endAdornment={
|
||||||
|
<InputAdornment
|
||||||
|
position="end"
|
||||||
|
sx={{
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: 'unset',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
borderLeft: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
ml: 0,
|
||||||
|
'& button': {
|
||||||
|
py: 0,
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BaseNumberField.Increment
|
||||||
|
render={<IconButton size={size} aria-label="Increase" />}
|
||||||
|
>
|
||||||
|
<KeyboardArrowUpIcon
|
||||||
|
fontSize={size}
|
||||||
|
sx={{ transform: 'translateY(2px)' }}
|
||||||
|
/>
|
||||||
|
</BaseNumberField.Increment>
|
||||||
|
|
||||||
|
<BaseNumberField.Decrement
|
||||||
|
render={<IconButton size={size} aria-label="Decrease" />}
|
||||||
|
>
|
||||||
|
<KeyboardArrowDownIcon
|
||||||
|
fontSize={size}
|
||||||
|
sx={{ transform: 'translateY(-2px)' }}
|
||||||
|
/>
|
||||||
|
</BaseNumberField.Decrement>
|
||||||
|
</InputAdornment>
|
||||||
|
}
|
||||||
|
sx={{ pr: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormHelperText id={`${id}-helper-text`} sx={{ ml: 0, '&:empty': { mt: 0 } }}>
|
||||||
|
Enter value between 10 and 40
|
||||||
|
</FormHelperText>
|
||||||
|
</BaseNumberField.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
frontend/src/components/result.tsx
Normal file
44
frontend/src/components/result.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
|
import SolicitorSummary from "../models/solicitorSummary";
|
||||||
|
import ListItemButton from "@mui/material/ListItemButton";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Rating from "@mui/material/Rating";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
|
||||||
|
type ResultProps = SolicitorSummary & { last : boolean, onClick: () => void };
|
||||||
|
|
||||||
|
export default function Result(props : ResultProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListItemButton
|
||||||
|
alignItems='flex-start'
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<>
|
||||||
|
<Typography
|
||||||
|
component='span'
|
||||||
|
variant='h6'
|
||||||
|
sx={{marginRight:'10px'}}
|
||||||
|
>
|
||||||
|
{props.name}
|
||||||
|
</Typography>
|
||||||
|
{props.rating &&
|
||||||
|
<Rating
|
||||||
|
readOnly
|
||||||
|
value={props.rating.value * 5.0 / props.rating.maximum}
|
||||||
|
precision={0.1}
|
||||||
|
size='small'
|
||||||
|
sx={{top: '2px'}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
secondary={props.shortDescription}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
{props.last || <Divider variant='middle' component="li" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/results.tsx
Normal file
46
frontend/src/components/results.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import GetSolicitors from "../functions/getSolicitors";
|
||||||
|
import Grid from "@mui/material/Grid";
|
||||||
|
import Sidebar from "./sidebar";
|
||||||
|
import FilterState from "../models/filterState";
|
||||||
|
import { default as ResultsType, waitingResults } from "../models/results";
|
||||||
|
import ResultsList from "./resultsList";
|
||||||
|
|
||||||
|
export default function Results() {
|
||||||
|
const [results, setResults] = useState<ResultsType>(waitingResults);
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
cities: [],
|
||||||
|
minRating: 3,
|
||||||
|
resultsPerPage: 20,
|
||||||
|
currentPage: 1,
|
||||||
|
ratingsProvider: 'Solicitors.com',
|
||||||
|
sortBy: 'RatingDescending',
|
||||||
|
namePart: '',
|
||||||
|
nameOnly: false});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (results.type == 'Waiting') {
|
||||||
|
GetSolicitors(filters)
|
||||||
|
.then(result => setResults({type: 'Success', data: result}));
|
||||||
|
}
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setResults({type: 'Waiting'});
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={0} sx={{minHeight: '100vh'}}>
|
||||||
|
<Grid size={4}>
|
||||||
|
<Sidebar
|
||||||
|
filters={filters}
|
||||||
|
onChange={setFilters}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={8}>
|
||||||
|
<ResultsList results={results} filters={filters} setFilters={setFilters} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
42
frontend/src/components/resultsList.tsx
Normal file
42
frontend/src/components/resultsList.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import List from "@mui/material/List";
|
||||||
|
import Pagination from "@mui/material/Pagination";
|
||||||
|
import FilterState from "../models/filterState";
|
||||||
|
import Results from "../models/results";
|
||||||
|
import Result from "./result";
|
||||||
|
import Detail, { DetailInfo } from "./detail";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface ResultsListProps {
|
||||||
|
results : Results;
|
||||||
|
filters : FilterState;
|
||||||
|
setFilters : (state : FilterState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResultsList(props : ResultsListProps) {
|
||||||
|
const [detail, setDetail] = useState<DetailInfo>({open: false});
|
||||||
|
|
||||||
|
switch (props.results.type) {
|
||||||
|
case 'Success':
|
||||||
|
let data = props.results.data.data;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<List sx={{ width: '100%', maxHeight: '85vh', overflow: 'scroll' }}>
|
||||||
|
{data.map((r, i) => (<Result {...r} onClick={() => setDetail({open : true, id : r.id})} last={i == data.length - 1} />))}
|
||||||
|
</List>
|
||||||
|
<Detail {...detail} onClose={() => setDetail({open: false})} />
|
||||||
|
<Pagination
|
||||||
|
page={props.filters.currentPage}
|
||||||
|
count={Math.ceil(props.results.data.total / props.filters.resultsPerPage)}
|
||||||
|
shape='rounded'
|
||||||
|
onChange={(_, page) => props.setFilters(Object.assign({}, {...props.filters}, {currentPage: page}))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'Waiting':
|
||||||
|
return <div>Waiting...</div>;
|
||||||
|
case 'Error':
|
||||||
|
return <div>Error...</div>;
|
||||||
|
case 'Warning':
|
||||||
|
return <div>Warning...</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
159
frontend/src/components/sidebar.tsx
Normal file
159
frontend/src/components/sidebar.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { Autocomplete, Box, Divider, Grid, InputLabel, MenuItem, Paper, Rating, Select, SelectChangeEvent, Switch, TextField, ToggleButton, Typography } from "@mui/material";
|
||||||
|
import getCities from "../functions/getCities";
|
||||||
|
import FilterState from "../models/filterState";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import NumberField from "./numberField";
|
||||||
|
import getRatingsProviders from "../functions/getRatingsProviders";
|
||||||
|
import capitaliseFirstLetter from "../functions/capitaliseFirstLetter";
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
filters : FilterState;
|
||||||
|
onChange : (filters : FilterState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar(props : SidebarProps) {
|
||||||
|
const [cities, setCities] = useState<string[]>([]);
|
||||||
|
const [ratingsProviders, setRatingsProviders] = useState<string[]>(['Solicitors.com']);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cities.length === 0) {
|
||||||
|
getCities().then((results) => setCities(results));
|
||||||
|
}
|
||||||
|
}, [cities.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cities.length === 0) {
|
||||||
|
getRatingsProviders().then((results) => setRatingsProviders(results));
|
||||||
|
}
|
||||||
|
}, [cities.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{margin: '8px', padding: '8px'}}>
|
||||||
|
<Box sx={{p: 2}}>
|
||||||
|
<Typography variant='h5' component='h2'>Filter Conveyancers</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ p: 2}}>
|
||||||
|
<Typography component='legend'>Firm Name</Typography>
|
||||||
|
<TextField
|
||||||
|
label='Firm Name'
|
||||||
|
variant='standard'
|
||||||
|
sx={{width: '100%'}}
|
||||||
|
value={props.filters.namePart}
|
||||||
|
onChange={e => props.onChange(Object.assign({}, {...props.filters}, {namePart: (e.target as any).value, currentPage: 1}))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Box sx={{ p: 2}}>
|
||||||
|
<Typography component='legend'>Search by Firm Name Only</Typography>
|
||||||
|
<Switch
|
||||||
|
value={props.filters.nameOnly}
|
||||||
|
onChange={(_, value) => props.onChange(Object.assign({}, {...props.filters}, {nameOnly: value, currentPage: 1}))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
{!props.filters.nameOnly &&
|
||||||
|
<>
|
||||||
|
<Box sx={{ p: 2}}>
|
||||||
|
<Typography component='legend'>Cities</Typography>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
options={cities}
|
||||||
|
value={props.filters.cities}
|
||||||
|
getOptionLabel={capitaliseFirstLetter}
|
||||||
|
onChange={(_, value) => props.onChange(Object.assign({}, {...props.filters}, {cities: value, currentPage: 1}))}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
variant="standard"
|
||||||
|
label="Cities"
|
||||||
|
placeholder=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Box sx={{p: 2}}>
|
||||||
|
<Grid container spacing={1} sx={{width: '100%'}}>
|
||||||
|
<Grid size={6}>
|
||||||
|
<Typography component='legend'>Min. Rating</Typography>
|
||||||
|
<Rating
|
||||||
|
name='Min. Rating'
|
||||||
|
precision={0.5}
|
||||||
|
value={props.filters.minRating}
|
||||||
|
size='medium'
|
||||||
|
onChange={(_, value) => props.onChange(Object.assign({}, {...props.filters}, {minRating: value, currentPage: 1}))}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<InputLabel id='ratings-label'>Rating Provider</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId='ratings-label'
|
||||||
|
value={props.filters.ratingsProvider}
|
||||||
|
onChange={(e : SelectChangeEvent) => props.onChange(Object.assign({}, {...props.filters}, {ratingsProvider: (e.target as any).value, currentPage: 1}))}
|
||||||
|
>
|
||||||
|
{ratingsProviders.map(provider => {
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
value={provider}
|
||||||
|
selected={provider === props.filters.ratingsProvider}>{capitaliseFirstLetter(provider)}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
</>}
|
||||||
|
<Box sx={{p: 2}}>
|
||||||
|
<Typography component='legend'>Results per Page</Typography>
|
||||||
|
<NumberField
|
||||||
|
label=""
|
||||||
|
value={props.filters.resultsPerPage}
|
||||||
|
onValueChange={value => props.onChange(Object.assign({}, {...props.filters}, {resultsPerPage: value, currentPage: 1}))}
|
||||||
|
min={10}
|
||||||
|
max={40}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Box sx={{p: 2}}>
|
||||||
|
<InputLabel id='sort-label'>Sort By</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId='sort-label'
|
||||||
|
value={props.filters.sortBy}
|
||||||
|
onChange={(e : SelectChangeEvent) => props.onChange(Object.assign({}, {...props.filters}, {sortBy: (e.target as any).value, currentPage: 1}))}
|
||||||
|
>
|
||||||
|
{sortingOptions.map(option => {
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
value={option.value}
|
||||||
|
selected={option.value === props.filters.sortBy}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortingOptions = [
|
||||||
|
{
|
||||||
|
value: 'RatingDescending',
|
||||||
|
name: 'Rating (descending)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'RatingAscending',
|
||||||
|
name: 'Rating (ascending)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'AlphabetAscending',
|
||||||
|
name: 'Alphabetical (ascending)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'AlphabetDescending',
|
||||||
|
name: 'Alphabetical (descending)'
|
||||||
|
}
|
||||||
|
]
|
||||||
7
frontend/src/functions/axios.ts
Normal file
7
frontend/src/functions/axios.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
3
frontend/src/functions/capitaliseFirstLetter.ts
Normal file
3
frontend/src/functions/capitaliseFirstLetter.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export default function capitaliseFirstLetter(val : string) {
|
||||||
|
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||||
|
}
|
||||||
6
frontend/src/functions/getCities.ts
Normal file
6
frontend/src/functions/getCities.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import api from "./axios";
|
||||||
|
|
||||||
|
export default async function GetCiries() {
|
||||||
|
const response = await api.get('/cities');
|
||||||
|
return response.data as string[];
|
||||||
|
}
|
||||||
6
frontend/src/functions/getRatingsProviders.ts
Normal file
6
frontend/src/functions/getRatingsProviders.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import api from "./axios";
|
||||||
|
|
||||||
|
export default async function GetCiries() {
|
||||||
|
const response = await api.get('/ratingsProviders');
|
||||||
|
return response.data as string[];
|
||||||
|
}
|
||||||
7
frontend/src/functions/getSolicitor.ts
Normal file
7
frontend/src/functions/getSolicitor.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { SolicitorInfo } from "../models/solicitorSummary";
|
||||||
|
import api from "./axios";
|
||||||
|
|
||||||
|
export default async function GetSolicitor(id : string) : Promise<SolicitorInfo | undefined> {
|
||||||
|
const response = await api.get(`/solicitors/${id}`);
|
||||||
|
return response.data as SolicitorInfo;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user