Merge pull request 'working' (#37) from working into main
Docker Build and Release Upload / build (push) Successful in 1m35s

Reviewed-on: #37
This commit was merged in pull request #37.
This commit is contained in:
2025-08-30 23:06:53 +00:00
22 changed files with 1826927 additions and 72 deletions
+7
View File
@@ -27,6 +27,7 @@ Client:
Job Board: Job Board:
Allow users to look up jobs and apply [ Boost visibility | Completely manual ] Allow users to look up jobs and apply [ Boost visibility | Completely manual ]
Mark ghost listings to allow users to be informed and put companies on blast Mark ghost listings to allow users to be informed and put companies on blast
Dont allow users to apply to the same job more than once
resume/viewer: resume/viewer:
CSS is broken CSS is broken
@@ -50,5 +51,11 @@ Client:
Need to impliment Add employee Need to impliment Add employee
Need to impliment Remove employee Need to impliment Remove employee
AI Resume Rating:
Allow companies to determine if the resume looks AI -> add rating
Translations:
Finish adding en strings to the Translations json
database: database:
Add Applied Jobs Table Add Applied Jobs Table
+1
View File
@@ -4,5 +4,6 @@ ENV MYSQL_DATABASE=boredcareers
ENV MYSQL_ROOT_PASSWORD=90pa8pav89h4g08hads ENV MYSQL_ROOT_PASSWORD=90pa8pav89h4g08hads
ADD mistox.sql /docker-entrypoint-initdb.d ADD mistox.sql /docker-entrypoint-initdb.d
ADD postalcodes.csv /var/lib/mysql-files/postalcodes.csv
EXPOSE 3306 EXPOSE 3306
+23
View File
@@ -183,6 +183,29 @@ CREATE TABLE IF NOT EXISTS `JobListingSkill` (
FOREIGN KEY (`JobListingID`) REFERENCES `JobListing`(`ID`) ON DELETE CASCADE FOREIGN KEY (`JobListingID`) REFERENCES `JobListing`(`ID`) ON DELETE CASCADE
) AUTO_INCREMENT=1; ) AUTO_INCREMENT=1;
-- Postal Codes Section
CREATE TABLE IF NOT EXISTS `PostalCodes` (
`country code` char(2),
`postal code` varchar(20),
`place name` varchar(180),
`state` varchar(100),
`state code` varchar(20),
`city` varchar(100),
`admin code2` varchar(20),
`admin name3` varchar(100),
`admin code3` varchar(20),
`latitude` float,
`longitude` float,
`accuracy` varchar(2)
);
LOAD DATA INFILE '/var/lib/mysql-files/postalcodes.csv'
INTO TABLE PostalCodes
FIELDS TERMINATED BY '\t'
ENCLOSED BY '"'
LINES TERMINATED BY '\n';
-- Application Section -- Application Section
CREATE TABLE IF NOT EXISTS `JobApplication` ( CREATE TABLE IF NOT EXISTS `JobApplication` (
+1826595
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
Postal codes is pulled from https://download.geonames.org/export/zip/
+1 -1
View File
@@ -19,7 +19,7 @@
"assets": [ "assets": [
{ {
"glob": "**/*", "glob": "**/*",
"input": "public" "input": "src/assets"
} }
], ],
"styles": [ "styles": [
+3 -1
View File
@@ -4,6 +4,7 @@ import { Authentication } from './services/Authentication';
import { CommonModule, Location } from '@angular/common'; import { CommonModule, Location } from '@angular/common';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { isDevMode } from '@angular/core'; import { isDevMode } from '@angular/core';
import { TranslationService } from './services/translations';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -16,7 +17,8 @@ export class App {
devMode: boolean = false; devMode: boolean = false;
loginToken: string | null = null; loginToken: string | null = null;
constructor( private http: HttpClient, public auth: Authentication, private router: Router, private route: ActivatedRoute, private location: Location){ constructor( private http: HttpClient, public auth: Authentication, private router: Router, private route: ActivatedRoute, private location: Location, public strings: TranslationService){
strings.setLanguage("en");
this.devMode = isDevMode(); this.devMode = isDevMode();
this.route.queryParams.subscribe(params => { this.route.queryParams.subscribe(params => {
this.loginToken = params['LoginToken']; this.loginToken = params['LoginToken'];
@@ -7,9 +7,9 @@
<h1>{{ application.rating }}</h1> <h1>{{ application.rating }}</h1>
<h1>{{ application.responseStatus }}</h1> <h1>{{ application.responseStatus }}</h1>
<h1>Date Applied: </h1><h1>{{ application.dateApplied }}</h1> <h1>{{ strings.translate("application/viewer", "s Date Applied") }}</h1><h1>{{ application.dateApplied }}</h1>
<button type="button" (click)="viewResume(application)" >VIEW RESUME</button> <button type="button" (click)="viewResume(application)" >{{ strings.translate("application/viewer", "b View Resume") }}</button>
</div> </div>
} }
</div> </div>
@@ -6,6 +6,7 @@ import { Title } from '@angular/platform-browser';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Authentication } from 'app/services/Authentication'; import { Authentication } from 'app/services/Authentication';
import { Application } from 'app/models/Application'; import { Application } from 'app/models/Application';
import { TranslationService } from 'app/services/translations';
@Component({ @Component({
selector: 'App-Viewer', selector: 'App-Viewer',
@@ -18,7 +19,7 @@ export class AppViewerComponent {
public List: Application[] = []; public List: Application[] = [];
constructor( private http: HttpClient, private router: Router, private route: ActivatedRoute, private title: Title, public auth: Authentication ) { constructor( private http: HttpClient, private router: Router, private route: ActivatedRoute, private title: Title, public auth: Authentication, public strings: TranslationService ) {
this.title.setTitle("Applications | BoredCareers"); this.title.setTitle("Applications | BoredCareers");
if (!auth.isLoggedIn){ if (!auth.isLoggedIn){
router.navigate(["/"]); router.navigate(["/"]);
@@ -46,7 +46,7 @@
<div class="center-text"> <div class="center-text">
<h1>{{ listing.title }}</h1> <h1>{{ listing.title }}</h1>
</div> </div>
<button [routerLink]="['/application/viewer']" [queryParams]="{ JobID: listing.id }" >VIEW LISTING</button> <button [routerLink]="['/application/viewer']" [queryParams]="{ JobID: listing.id }" >VIEW Applicants</button>
<button [routerLink]="['/jobs/viewer']" [queryParams]="{ JobID: listing.id }" >VIEW LISTING</button> <button [routerLink]="['/jobs/viewer']" [queryParams]="{ JobID: listing.id }" >VIEW LISTING</button>
<button [routerLink]="['/jobs/editor']" [queryParams]="{ JobID: listing.id }" >EDIT LISTING</button> <button [routerLink]="['/jobs/editor']" [queryParams]="{ JobID: listing.id }" >EDIT LISTING</button>
<button (click)="RemoveJobListing(listing.id!)">DELETE LISTING</button> <button (click)="RemoveJobListing(listing.id!)">DELETE LISTING</button>
@@ -109,3 +109,13 @@ form {
height: 40px; height: 40px;
break-inside: avoid; break-inside: avoid;
} }
.error-window {
position: absolute;
left: calc(50% - 400px);
width: 800px;
text-align: center;
top: 600px;
background-color: red;
border-radius: 10px;
}
@@ -22,7 +22,7 @@
<div class="center"> <div class="center">
<div class="content-frame"> <div class="content-frame">
<label>Company Website URL</label> <label>Company Website URL</label>
<input class="input-field" name="url" [(ngModel)]="newListing.websiteURL" type="text" placeholder="https://mistox.com/" /> <input class="input-field" name="url" [(ngModel)]="newListing.websiteURL" type="text" (blur)="validateURL(newListing.websiteURL)" placeholder="https://mistox.com/" />
<button type="button" (click)="prevStep()">Back</button> <button type="button" (click)="prevStep()">Back</button>
<button type="button" (click)="nextStep()">Next</button> <button type="button" (click)="nextStep()">Next</button>
</div> </div>
@@ -168,3 +168,9 @@
</div> </div>
</div> </div>
</form> </form>
@if (ErrorMsg != ""){
<div class="error-window">
<h1>{{ ErrorMsg }}</h1>
</div>
}
@@ -62,9 +62,12 @@ export class CompanyEditorComponent {
validateEmail(input: string){ validateEmail(input: string){
let result = this.validator.validateEmail(input); let result = this.validator.validateEmail(input);
if (result[0]){
this.newListing.email = result[1]; this.newListing.email = result[1];
} }
validateURL(input: string){
let result = this.validator.validateURL(input);
this.newListing.websiteURL = result[1];
} }
@HostListener('window:keydown', ['$event']) @HostListener('window:keydown', ['$event'])
@@ -146,6 +149,13 @@ export class CompanyEditorComponent {
return; return;
} }
var urlIsValid = this.validator.validateURL(company.websiteURL);
if (urlIsValid[0] == false){
this.ErrorMsg = urlIsValid[1];
this.focusFrame(1, 0);
return;
}
if (this.isNullOrEmpty(company.logo)){ if (this.isNullOrEmpty(company.logo)){
this.ErrorMsg = "Logo is blank"; this.ErrorMsg = "Logo is blank";
this.focusFrame(2, 0); this.focusFrame(2, 0);
@@ -158,8 +168,9 @@ export class CompanyEditorComponent {
return; return;
} }
if (this.validator.validateEmail(company.email)[0]){ var emailIsValid = this.validator.validateEmail(company.email);
this.ErrorMsg = "Email is invalid"; if (emailIsValid[0] == false){
this.ErrorMsg = emailIsValid[1];
this.focusFrame(3, 0); this.focusFrame(3, 0);
return; return;
} }
@@ -170,7 +181,7 @@ export class CompanyEditorComponent {
return; return;
} }
if (this.validator.validatePhoneNumber(company.phone)[0]){ if (this.validator.validatePhoneNumber(company.phone)[0] == false){
this.ErrorMsg = "Phone number is invalid"; this.ErrorMsg = "Phone number is invalid";
this.focusFrame(3, 1); this.focusFrame(3, 1);
return; return;
@@ -2,7 +2,7 @@
<div class="center-frame center-text border"> <div class="center-frame center-text border">
<h1>Bored Careers</h1> <h1>Bored Careers</h1>
<h2>The Anti-AI Job Board</h2> <h2>{{ strings.translate("home", "s subTitle") }}</h2>
</div> </div>
<div class="content-frame border"> <div class="content-frame border">
@@ -4,6 +4,8 @@ import { FormsModule } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslationService } from 'app/services/translations';
import { Observable } from 'rxjs';
@Component({ @Component({
selector: 'main-home', selector: 'main-home',
@@ -13,8 +15,12 @@ import { CommonModule } from '@angular/common';
}) })
export class HomeComponent { export class HomeComponent {
constructor( private http: HttpClient, private router: Router, private route: ActivatedRoute, private title: Title ) { Loaded$: Observable<boolean>;
constructor( private http: HttpClient, private router: Router, private route: ActivatedRoute, private title: Title, public strings: TranslationService ) {
this.title.setTitle("Home | BoredCareers"); this.title.setTitle("Home | BoredCareers");
this.Loaded$ = this.strings.changed$;
}; };
} }
@@ -58,10 +58,13 @@
} }
.content-desc { .content-desc {
border: solid 1px red;
border-radius: 5px;
padding: 20px; padding: 20px;
border-radius: 20px;
margin: 0 100px; margin: 0 100px;
background-color: var(--mistox-bg-medium);
border: 1px solid var(--mistox-border);
box-shadow: var(--mistox-shadow);
color: var(--mistox-text);
} }
.content-desc h1 { .content-desc h1 {
@@ -83,9 +86,75 @@
} }
.job-timestamp { .job-timestamp {
display: flex;
width: 100%; width: 100%;
justify-content: end;
} }
.job-timestamp h1 { .job-warning {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
width: 100%;
background-color: #f00;
}
.split {
column-count: 2;
margin: 0 100px;
}
.nobreak {
break-inside: avoid;
padding: 20px;
border-radius: 20px;
margin-bottom: 20px;
background-color: var(--mistox-bg-medium);
border: 1px solid var(--mistox-border);
box-shadow: var(--mistox-shadow);
color: var(--mistox-text);
}
.detail-block {
padding: 20px;
border-radius: 20px;
margin-bottom: 20px;
background-color: var(--mistox-bg-light);
border: 1px solid var(--mistox-border);
box-shadow: var(--mistox-shadow);
color: var(--mistox-text);
}
.detail-block h1 {
margin: 0; margin: 0;
} }
.detail-block h2 {
margin: 0;
}
.description-box {
padding: 20px;
border-radius: 20px;
margin-bottom: 20px;
background-color: var(--mistox-bg-light);
border: 1px solid var(--mistox-border);
box-shadow: var(--mistox-shadow);
color: var(--mistox-text);
}
.description-box h1 {
margin: 0;
padding: 0;
font-size: 20px;
}
.skill-combo {
column-count: 2;
}
.bottom-bar {
display: grid;
}
@@ -23,46 +23,72 @@
} }
@if (selectedJob != null) { @if (selectedJob != null) {
<div class="job-details"> <div class="job-details">
<div class="job-timestamp">
<h1>Opened: {{ selectedJob.createdTime }}</h1>
<h1>Modified: {{ selectedJob.modifiedTime }}</h1>
</div>
@if (selectedJob.isDeleted){ @if (selectedJob.isDeleted){
<div class="job-warning"> <div class="job-warning">
<h2>THIS JOB POSTING IS CLOSED</h2> <h2>THIS JOB POSTING IS CLOSED</h2>
</div> </div>
} }
<div class="center-item">
<h1>{{ selectedJob.title }}</h1> <h1>{{ selectedJob.title }}</h1>
</div>
<div class="split">
<div class="nobreak">
<h1>Job Details:</h1>
<div class="detail-block">
<h1>Job Type</h1>
<h2>{{ selectedJob.jobType }}</h2>
</div>
<h1>{{ selectedJob.jobType }}</h1> <div class="detail-block">
<h1>{{ selectedJob.remote }}</h1> <h1>In Office:</h1>
@if (selectedJob.remote){
<h2>Remote</h2>
} @else {
<h2>On-Site</h2>
}
</div>
<h1>{{ selectedJob.salaryMin }}</h1> <div class="detail-block">
<h1>{{ selectedJob.salaryMax }}</h1> <h1>Pay Range:</h1>
<h2>{{ selectedJob.salaryMin }} - {{ selectedJob.salaryMax }}</h2>
</div>
<h1>{{ selectedJob.city }}</h1> @if (!selectedJob.remote){
<h1>{{ selectedJob.stateOrRegion }}</h1> <div class="detail-block">
<h1>{{ selectedJob.country }}</h1> <h1>Location:</h1>
<h1>{{ selectedJob.postalCode }}</h1> <h2>{{ selectedJob.city }}, {{ selectedJob.stateOrRegion }} {{ selectedJob.country }} {{ selectedJob.postalCode }}</h2>
</div>
}
<div> <div class="detail-block">
<h1>Required Skills</h1> <h1>Required Skills:</h1>
@for(skill of selectedJob.skills; track skill.trackUUID){ @for(skill of selectedJob.skills; track skill.trackUUID){
<div> <div class="skill-combo">
<div class="nobreak">
<h1>{{ skill.name }}</h1> <h1>{{ skill.name }}</h1>
<h1>{{ skill.description }}</h1> </div>
<div class="nobreak">
<h2>{{ skill.description }}</h2>
</div>
</div> </div>
} }
</div> </div>
<h1>{{ selectedJob.description }}</h1> <div class="job-timestamp">
<div> <span>Opened: {{ selectedJob.createdTime }} | Last Updated: {{ selectedJob.modifiedTime }}</span>
@for(resume of myResumes; track resume.trackUUID){
<div>
<button type="button" (click)="applyWithResume(resume)">APPLY USING RESUME: {{ resume.name }}</button>
</div> </div>
</div>
<div class="nobreak">
<h1>Job Description: </h1>
<div class="description-box">
<h1>{{ selectedJob.description }}</h1>
</div>
</div>
</div>
<div class="bottom-bar">
@for(resume of myResumes; track resume.trackUUID){
<button type="button" (click)="applyWithResume(resume)">APPLY USING RESUME: {{ resume.name }}</button>
} }
</div> </div>
</div> </div>
@@ -5,7 +5,6 @@
</div> </div>
<h1>Public: </h1><input [name]="'active' + resume.trackUUID" [(ngModel)]="resume.isActive" type="checkbox" /> <h1>Public: </h1><input [name]="'active' + resume.trackUUID" [(ngModel)]="resume.isActive" type="checkbox" />
<h1>Is Veteran: </h1><input [name]="'veteran' + resume.military?.trackUUID" type="checkbox" [checked]="resume.military !== null" (change)="onVeteranChange($event)" /> <h1>Is Veteran: </h1><input [name]="'veteran' + resume.military?.trackUUID" type="checkbox" [checked]="resume.military !== null" (change)="onVeteranChange($event)" />
<button type="button" (click)="PrintResume()">Print</button>
</div> </div>
<div class="paper"> <div class="paper">
@@ -140,7 +139,7 @@
<div class="resume-sub-section flex-two-row"> <div class="resume-sub-section flex-two-row">
<div> <div>
<input [name]="'certname' + cert.trackUUID" [(ngModel)]="cert.name" type="text" placeholder="Certification Name" /> <input [name]="'certname' + cert.trackUUID" [(ngModel)]="cert.name" type="text" placeholder="Certification Name" />
<input [name]="'certverificationURL' + cert.trackUUID" [(ngModel)]="cert.verificationURL" type="text" placeholder="Verification URL" /> <input [name]="'certverificationURL' + cert.trackUUID" [(ngModel)]="cert.verificationURL" type="text" (blur)="validateCertURL(cert)" placeholder="Verification URL" />
</div> </div>
<textarea [name]="'certdescription' + cert.trackUUID" [(ngModel)]="cert.description" placeholder="Description"></textarea> <textarea [name]="'certdescription' + cert.trackUUID" [(ngModel)]="cert.description" placeholder="Description"></textarea>
</div> </div>
@@ -156,7 +155,7 @@
<div class="resume-sub-section flex-two-row"> <div class="resume-sub-section flex-two-row">
<div> <div>
<input [name]="'projname' + proj.trackUUID" [(ngModel)]="proj.name" type="text" placeholder="Project Name" /> <input [name]="'projname' + proj.trackUUID" [(ngModel)]="proj.name" type="text" placeholder="Project Name" />
<input [name]="'projurl' + proj.trackUUID" [(ngModel)]="proj.url" type="text" placeholder="Reference URL" /> <input [name]="'projurl' + proj.trackUUID" [(ngModel)]="proj.url" type="text" (blur)="validateProjectURL(proj)" placeholder="Reference URL" />
</div> </div>
<textarea [name]="'projdescription' + proj.trackUUID" [(ngModel)]="proj.description" placeholder="Description"></textarea> <textarea [name]="'projdescription' + proj.trackUUID" [(ngModel)]="proj.description" placeholder="Description"></textarea>
</div> </div>
@@ -90,32 +90,22 @@ export class ResumesEditorComponent {
validatePhone(input: string){ validatePhone(input: string){
let result = this.validator.validatePhoneNumber(input); let result = this.validator.validatePhoneNumber(input);
if (result[0]){
this.resume.phoneNumber = result[1]; this.resume.phoneNumber = result[1];
} }
}
validateEmail(input: string){ validateEmail(input: string){
let result = this.validator.validateEmail(input); let result = this.validator.validateEmail(input);
if (result[0]){
this.resume.email = result[1]; this.resume.email = result[1];
} }
validateCertURL(input: ResumeCertification){
let result = this.validator.validateURL(input.verificationURL);
input.verificationURL = result[1];
} }
PrintResume(){ validateProjectURL(input: ResumeProject){
const divToPrint = document.getElementsByClassName("paper")[0]; let result = this.validator.validateURL(input.url);
input.url = result[1];
const printContents = divToPrint.innerHTML;
const originalContents = document.body.innerHTML; // Store original body content
// Temporarily replace the body content with the div's content
document.body.innerHTML = printContents;
// Trigger the print dialog
window.print();
// Restore the original body content
document.body.innerHTML = originalContents;
} }
addExperience(){ addExperience(){
+56 -2
View File
@@ -25,11 +25,65 @@ export class Validation {
} }
} }
emailRegex: RegExp = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/; emailRegex: RegExp = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
validateEmail(input: string): [boolean, string] { validateEmail(input: string): [boolean, string] {
const corrected = input.trim().toLowerCase(); const trimmed = input.trim();
const [local, domain] = trimmed.split('@');
const corrected = domain ? `${local}@${domain.toLowerCase()}` : trimmed;
// Extra checks to avoid invalid formats
if ( corrected.includes('..') || corrected.startsWith('.') || corrected.endsWith('.') ) {
return [false, corrected];
}
const success = this.emailRegex.test(corrected); const success = this.emailRegex.test(corrected);
return [success, corrected]; return [success, corrected];
} }
validateURL(input: string): [boolean, string] {
const testUrl = /^https?:\/\//i.test(input) ? input : "http://" + input;
var urlToParse = new URL(testUrl);
try {
if (urlToParse.protocol !== 'http:' && urlToParse.protocol !== 'https:') {
return [false, "bad protocol"];
}
const hostname = urlToParse.hostname.toLowerCase();
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
return [false, "localhost not allowed"];
}
const privateIpRegex = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/;
if (privateIpRegex.test(hostname)) {
return [false, "internal ipv4 not allowed"];
}
if (hostname.includes(':')) {
if (this.isPrivateIPv6(hostname)) {
return [false, "internal ipv6 not allowed"];
}
}
return [true, urlToParse.href.substring(0, urlToParse.href.length - 1)];
} catch (e) {
return [false, "validation error has occurred"];
}
}
///////// 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;
}
}
} }
@@ -0,0 +1,38 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class TranslationService {
private _translations: any = {};
private _translationsLoaded: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
constructor(private http: HttpClient) {}
// Change language dynamically
setLanguage(lang: string) {
this.http.get(`/lang/${lang}.json`).subscribe({
next: data => {
this._translations = data;
this._translationsLoaded.next(true);
},
error: err => {
this._translations = [];
this._translationsLoaded.next(true);
}
})
}
// Observable for checking if language is loaded
get changed$(): Observable<boolean> {
return this._translationsLoaded.asObservable();
}
// Get a specific translation
translate(page: string, key: string): string {
return this._translations[page][key] || key;
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"home": {
"s subTitle": "The Anti AI Job Board",
"emailPlaceholder": "Enter your email",
"passwordPlaceholder": "Enter your password",
"loginButton": "Login"
},
"application/viewer": {
"s Date Applied": "Date Applied: ",
"b View Resume": "VIEW RESUME"
},
"profile": {
"pageTitle": "Your Profile",
"editButton": "Edit Profile"
}
}