From 73c1e1ce98a3ff112af1fa5789e68d41e8b7248a Mon Sep 17 00:00:00 2001 From: Derek Holloway Date: Tue, 2 Sep 2025 22:31:05 -0700 Subject: [PATCH] Start work on filtering logic --- ToDo.yaml | 13 ++++ database/mistox.sql | 4 +- src/Client/src/app/models/JobFilter.ts | 11 +++ .../src/app/pages/jobs/jobs.component.html | 36 ++++++++++ .../src/app/pages/jobs/jobs.component.ts | 41 ++++++++++- src/Client/src/app/services/Validation.ts | 35 +++++++--- .../Controllers/JobListingController.cs | 22 +++++- src/Server/Controllers/LocationController.cs | 12 +--- .../Services/DatabaseService/JobListing.cs | 70 ++++++++++++++++--- .../Services/DatabaseService/Location.cs | 69 ++++++------------ 10 files changed, 230 insertions(+), 83 deletions(-) create mode 100644 src/Client/src/app/models/JobFilter.ts diff --git a/ToDo.yaml b/ToDo.yaml index 883180f..80a2eb7 100755 --- a/ToDo.yaml +++ b/ToDo.yaml @@ -15,6 +15,19 @@ Server: Emails: Make emails follow theme of website better + JobListingController: + Dont refresh on every filter edit + Line 63 is terrible and need to be fixed in the JobListing DB JobListingController + bools and numbers are getting strigified which breaks the mysql parameters + + Validation: + Alot of the validation is only taking place client side. + Need to validate all inputs before processing + Phone number + Email Address + City, CountryCode, PostalCode + When applying to a job, The server doesnt make sure that the company only fields are not modified + Client: jobs/editor: Want to add completed job listing preview at end of carosel diff --git a/database/mistox.sql b/database/mistox.sql index 2d08131..068b5b0 100755 --- a/database/mistox.sql +++ b/database/mistox.sql @@ -193,8 +193,8 @@ CREATE TABLE IF NOT EXISTS `PostalCodes` ( `StateCode` varchar(20), `County` varchar(100), `CountyCode` varchar(20), - `Admin` varchar(100), - `AdminCode` varchar(20), + `Community` varchar(100), + `CommunityCode` varchar(20), `Latitude` float, `Longitude` float, `Accuracy` varchar(2) diff --git a/src/Client/src/app/models/JobFilter.ts b/src/Client/src/app/models/JobFilter.ts new file mode 100644 index 0000000..7ccd085 --- /dev/null +++ b/src/Client/src/app/models/JobFilter.ts @@ -0,0 +1,11 @@ +export class JobFilter { + public JobsPerPage: number = 20; + public CurrentPage: number = 1; + public CountryCode: string | null = null; + public PostalCode: string | null = null; + public Distance: number | null = null; + public JobType: string | null = null; + public Remote: boolean | null = null; + public SalaryMin: number | null = null; + public SalaryMax: number | null = null; +} \ No newline at end of file diff --git a/src/Client/src/app/pages/jobs/jobs.component.html b/src/Client/src/app/pages/jobs/jobs.component.html index cdfd17c..60fd90c 100644 --- a/src/Client/src/app/pages/jobs/jobs.component.html +++ b/src/Client/src/app/pages/jobs/jobs.component.html @@ -1,3 +1,35 @@ + +
+
+

Country

+ +
+
+

Postal Code

+ +
+
+

Distance

+ +
+
+

Job Type

+ +
+
+

Remote

+ +
+
+

Minimum Salary

+ +
+
+

Maximum Salary

+ +
+
+
@for (cur of JobListingPage; track cur.id){ @@ -21,4 +53,8 @@
} +
+

Jobs Per Page

+ +
\ No newline at end of file diff --git a/src/Client/src/app/pages/jobs/jobs.component.ts b/src/Client/src/app/pages/jobs/jobs.component.ts index 827bd86..04b71d3 100644 --- a/src/Client/src/app/pages/jobs/jobs.component.ts +++ b/src/Client/src/app/pages/jobs/jobs.component.ts @@ -6,6 +6,7 @@ import { Title } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; import { JobListing } from 'app/models/JobListing'; import { Authentication } from 'app/services/Authentication'; +import { JobFilter } from 'app/models/JobFilter'; @Component({ selector: 'main-jobs', @@ -15,7 +16,7 @@ import { Authentication } from 'app/services/Authentication'; }) export class JobsComponent { - public MyJobListings: JobListing[] = []; + public currentFilter: JobFilter; public JobListingPage: JobListing[] = []; public ErrorMsg: string = ""; @@ -23,10 +24,46 @@ export class JobsComponent { constructor( private http: HttpClient, private router: Router, private route: ActivatedRoute, private title: Title, public auth: Authentication ) { this.title.setTitle("Jobs | BoredCareers"); + this.currentFilter = new JobFilter(); }; ngOnInit(){ - this.http.get("api/joblisting?PageQuantity=" + 10 + "&Page=" + 1).subscribe({ + this.http.get("api/joblisting?PageQuantity=" + this.currentFilter.JobsPerPage + "&Page=" + this.currentFilter.CurrentPage).subscribe({ + next: data => { + this.JobListingPage = data; + }, + error: err => { + this.ErrorMsg = err.error; + } + }); + } + + reloadFilters(){ + var queryBuilder = "api/joblisting?PageQuantity=" + this.currentFilter.JobsPerPage + "&Page=" + this.currentFilter.CurrentPage; + + if (this.currentFilter.PostalCode != null){ + queryBuilder += "&PC=" + this.currentFilter.PostalCode; + } + if (this.currentFilter.CountryCode != null){ + queryBuilder += "&CC=" + this.currentFilter.CountryCode; + } + if (this.currentFilter.Distance != null){ + queryBuilder += "&D=" + this.currentFilter.Distance; + } + if (this.currentFilter.JobType != null){ + queryBuilder += "&JT=" + this.currentFilter.JobType; + } + if (this.currentFilter.Remote != null){ + queryBuilder += "&R=" + this.currentFilter.Remote; + } + if (this.currentFilter.SalaryMin != null){ + queryBuilder += "&SMI=" + this.currentFilter.SalaryMin; + } + if (this.currentFilter.SalaryMax != null){ + queryBuilder += "&SMA=" + this.currentFilter.SalaryMax; + } + + this.http.get(queryBuilder).subscribe({ next: data => { this.JobListingPage = data; }, diff --git a/src/Client/src/app/services/Validation.ts b/src/Client/src/app/services/Validation.ts index 0887ad9..ddec9c9 100644 --- a/src/Client/src/app/services/Validation.ts +++ b/src/Client/src/app/services/Validation.ts @@ -74,16 +74,31 @@ export class Validation { ///////// HELPER FUNCTIONS ///////// isPrivateIPv6(ip: string): boolean { - try { - const normalized = ip.replace(/^\[|\]$/g, '').toLowerCase(); - if (normalized === '::1') return true; - if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; - const first4 = normalized.slice(0, 4); - if (first4 >= 'fe80' && first4 <= 'febf') return true; - return false; - } catch { - return true; + try { + const normalized = ip.replace(/^\[|\]$/g, '').toLowerCase(); + if (normalized === '::1') return true; + if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; + const first4 = normalized.slice(0, 4); + if (first4 >= 'fe80' && first4 <= 'febf') return true; + return false; + } catch { + return true; + } + } + + ///////// GETTERS ///////// + + get ValidCountries(): string[] { + return ['AD', 'AE', 'AI', 'AL', 'AR', 'AS', 'AT', 'AU', 'AX', 'AZ', 'BD', 'BE', 'BG', + 'BM', 'BR', 'BY', 'CA', 'CC', 'CH', 'CL', 'CN', 'CO', 'CR', 'CX', 'CY', 'CZ', + 'DE', 'DK', 'DO', 'DZ', 'EC', 'EE', 'ES', 'FI', 'FK', 'FM', 'FO', 'FR', 'GB', + 'GF', 'GG', 'GI', 'GL', 'GP', 'GS', 'GT', 'GU', 'HK', 'HM', 'HN', 'HR', 'HT', + 'HU', 'ID', 'IE', 'IM', 'IN', 'IO', 'IS', 'IT', 'JE', 'JP', 'KE', 'KR', 'LI', + 'LK', 'LT', 'LU', 'LV', 'MA', 'MC', 'MD', 'MH', 'MK', 'MO', 'MP', 'MQ', 'MT', + 'MW', 'MX', 'MY', 'NC', 'NF', 'NL', 'NO', 'NR', 'NU', 'NZ', 'PA', 'PE', 'PF', + 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PT', 'PW', 'RE', 'RO', 'RS', 'RU', 'SE', + 'SG', 'SI', 'SJ', 'SK', 'SM', 'TC', 'TH', 'TR', 'UA', 'US', 'UY', 'VA', 'VI', + 'WF', 'WS', 'YT', 'ZA']; } -} } \ No newline at end of file diff --git a/src/Server/Controllers/JobListingController.cs b/src/Server/Controllers/JobListingController.cs index 18e90c3..647050c 100644 --- a/src/Server/Controllers/JobListingController.cs +++ b/src/Server/Controllers/JobListingController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using BoredCareers.Services.DatabaseService; using BoredCareers.Entities; +using Org.BouncyCastle.Tls; namespace BoredCareers.Controllers { [ApiController] @@ -31,8 +32,25 @@ namespace BoredCareers.Controllers { } [HttpGet] - public async Task GetJobListings(int Page = 1, int PageQuantity = 25) { - JobListing[] jobListings = await _databaseService.GetJobListingPage(Page, PageQuantity); + public async Task GetJobListings(string? CC, string? PC, float? D, string? JT, bool? R, int? SMI, int? SMA, int Page = 1, int PageQuantity = 25 ) { + + // Get all relevant postal codes + List PostalCodes = new List(); + if (PC != null && CC != null && D != null) { + Location[] nearby = await _databaseService.GetNearbyLocations(PC, CC, D.Value); + foreach (Location cur in nearby) { + PostalCodes.Add(cur.PostalCode); + } + } else if (PC != null) { + PostalCodes.Add(PC); + } + + string[]? pc = null; + if (PostalCodes.Count > 0) { + pc = PostalCodes.ToArray(); + } + + JobListing[] jobListings = await _databaseService.GetJobListingPage(Page, PageQuantity, pc, CC, JT, R, SMI, SMA ); return Ok(jobListings); } diff --git a/src/Server/Controllers/LocationController.cs b/src/Server/Controllers/LocationController.cs index e855975..90ab731 100644 --- a/src/Server/Controllers/LocationController.cs +++ b/src/Server/Controllers/LocationController.cs @@ -1,25 +1,19 @@ using Microsoft.AspNetCore.Mvc; using BoredCareers.Services.DatabaseService; using BoredCareers.Entities; -using BoredCareers.Services; namespace BoredCareers.Controllers { [ApiController] [Route("api/location")] public class LocationController : MistoxControllerBase { - public LocationController(DatabaseService db, EmailService emailContext) : base(db) {} + public LocationController(DatabaseService db) : base(db) {} [HttpGet] public async Task GetCompany(string PostalCode, string CountryCode, float MaxDistanceKm) { if (isLoggedIn()) { - - Location? MyZIP = await _databaseService.GetLocation(PostalCode, CountryCode); - if (MyZIP != null) { - Location[] places = await _databaseService.GetNearbyLocations(MyZIP.Latitude, MyZIP.Longitude, MyZIP.CountryCode, MaxDistanceKm); - return Ok(places.ToArray()); - } - return NotFound("Postal + CountryCode not found"); + Location[] places = await _databaseService.GetNearbyLocations(PostalCode, CountryCode, MaxDistanceKm); + return Ok(places.ToArray()); } return NotFound("Not logged in"); } diff --git a/src/Server/Services/DatabaseService/JobListing.cs b/src/Server/Services/DatabaseService/JobListing.cs index 8dad416..28029ca 100644 --- a/src/Server/Services/DatabaseService/JobListing.cs +++ b/src/Server/Services/DatabaseService/JobListing.cs @@ -7,21 +7,73 @@ using System.Data.Common; namespace BoredCareers.Services.DatabaseService { public partial class DatabaseService { - public async Task GetJobListingPage(int PageNumber, int CountPerPage) { + public async Task GetJobListingPage(int PageNumber, int CountPerPage, string[]? PostalCodes = null, string? CountryCode = null, string? JobType = null, bool? Remote = null, int? SalaryMin = null, int? SalaryMax = null ) { List joblistings = new List(); using (MySqlConnection connection = GetConnection()) { await connection.OpenAsync(); - string command = @" - SELECT * - FROM JobListing - WHERE IsDeleted = FALSE - ORDER BY CreatedTime DESC - LIMIT @PageSize OFFSET @PageNumber; - "; - MySqlCommand cmd = new MySqlCommand(command, connection); + string select = "SELECT * FROM JobListing"; + string order = " ORDER BY CreatedTime DESC"; + string limit = " LIMIT @PageSize OFFSET @PageNumber;"; + + List Filters = new List(); + List Parameters = new List(); + List ParameterName = new List(); + + if (PostalCodes != null) { + for (int i = 0; i < PostalCodes.Length; i++) { + Filters.Add("PostalCode"); + Parameters.Add(PostalCodes[i]); + ParameterName.Add("@PostalCode" + i); + } + } + + if (CountryCode != null) { + Filters.Add("Country"); + Parameters.Add(CountryCode); + ParameterName.Add("@CountryCode"); + } + + if (JobType != null) { + Filters.Add("JobType"); + Parameters.Add(JobType); + ParameterName.Add("@JobType"); + } + + if (Remote != null) { + Filters.Add("Remote"); + Parameters.Add(Remote.Value.ToString()); + ParameterName.Add("@Remote"); + } + + if (SalaryMin != null) { + Filters.Add("SalaryMin"); + Parameters.Add(SalaryMin.Value.ToString()); + ParameterName.Add("@SalaryMin"); + } + + if (SalaryMax != null) { + Filters.Add("SalaryMax"); + Parameters.Add(SalaryMax.Value.ToString()); + ParameterName.Add("@SalaryMax"); + } + + string filter = " WHERE IsDeleted = False"; + for (int i = 0; i < Filters.Count; i++) { + if (Filters[i] == "PostalCode" && i != 0) { + filter += " OR "; + } else { + filter += " AND "; + } + filter += Filters[i] + " = " + ParameterName[i]; + } + + MySqlCommand cmd = new MySqlCommand(select + filter + order + limit, connection); cmd.Parameters.AddWithValue("@PageSize", CountPerPage); cmd.Parameters.AddWithValue("@PageNumber", (PageNumber - 1) * CountPerPage); + for (int i = 0; i < Filters.Count; i++) { + cmd.Parameters.AddWithValue( ParameterName[i], Parameters[i] ); + } using (DbDataReader reader = await cmd.ExecuteReaderAsync()) { while (await reader.ReadAsync()) { diff --git a/src/Server/Services/DatabaseService/Location.cs b/src/Server/Services/DatabaseService/Location.cs index 467d00b..16efe83 100644 --- a/src/Server/Services/DatabaseService/Location.cs +++ b/src/Server/Services/DatabaseService/Location.cs @@ -7,64 +7,35 @@ using System.Text; namespace BoredCareers.Services.DatabaseService { public partial class DatabaseService { - public async Task GetLocation(string PostalCode, string CountryCode) { - using (MySqlConnection connection = GetConnection()) { - await connection.OpenAsync(); - string command = @" - SELECT PostalCode, CountryCode, Latitude, Longitude, City - FROM PostalCodes - WHERE PostalCode = @PostalCode - AND CountryCode = @CountryCode; - "; - - MySqlCommand cmd = new MySqlCommand(command, connection); - cmd.Parameters.AddWithValue("@PostalCode", PostalCode); - cmd.Parameters.AddWithValue("@CountryCode", CountryCode); - - using (DbDataReader reader = await cmd.ExecuteReaderAsync()) { - while (await reader.ReadAsync()) { - string _city = reader.GetString("City"); - string _postalCode = reader.GetString("PostalCode"); - string _countryCode = reader.GetString("CountryCode"); - float _latitude = reader.GetFloat("Latitude"); - float _longitude = reader.GetFloat("Longitude"); - - return new Location() { - City = _city, - PostalCode = _postalCode, - CountryCode = _countryCode, - Latitude = _latitude, - Longitude = _longitude - }; - } - } - } - return null; - } - - public async Task GetNearbyLocations(float Latitude, float Longitude, string CountryCode, float MaxDistanceKm) { + public async Task GetNearbyLocations(string PostalCode, string CountryCode, float MaxDistanceKm) { List closePostalCodes = new List(); using (MySqlConnection connection = GetConnection()) { await connection.OpenAsync(); string command = @" - SELECT PostalCode, CountryCode, Latitude, Longitude, City, ( - 6371 * acos( - cos(radians(@Latitude)) * - cos(radians(Latitude)) * - cos(radians(Longitude) - radians(@Longitude)) + - sin(radians(@Latitude)) * - sin(radians(Latitude)) - ) - ) AS distance_km - FROM PostalCodes - WHERE countrycode = @CountryCode + SELECT + pc2.PostalCode, + pc2.CountryCode, + pc2.Latitude, + pc2.Longitude, + pc2.City, + ( + 6371 * acos( + cos(radians(pc1.Latitude)) * + cos(radians(pc2.Latitude)) * + cos(radians(pc2.Longitude) - radians(pc1.Longitude)) + + sin(radians(pc1.Latitude)) * + sin(radians(pc2.Latitude)) + ) + ) AS distance_km + FROM PostalCodes pc1 + JOIN PostalCodes pc2 ON pc2.CountryCode = 'us' + WHERE pc1.PostalCode = @PostalCode AND pc1.CountryCode = @CountryCode HAVING distance_km <= @MaxDistanceKm ORDER BY distance_km; "; MySqlCommand cmd = new MySqlCommand(command, connection); - cmd.Parameters.AddWithValue("@Latitude", Latitude); - cmd.Parameters.AddWithValue("@Longitude", Longitude); + cmd.Parameters.AddWithValue("@PostalCode", PostalCode); cmd.Parameters.AddWithValue("@CountryCode", CountryCode); cmd.Parameters.AddWithValue("@MaxDistanceKm", MaxDistanceKm);