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:
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
Dont allow users to apply to the same job more than once
resume/viewer:
CSS is broken
@@ -50,5 +51,11 @@ Client:
Need to impliment Add 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:
Add Applied Jobs Table
+1
View File
@@ -4,5 +4,6 @@ ENV MYSQL_DATABASE=boredcareers
ENV MYSQL_ROOT_PASSWORD=90pa8pav89h4g08hads
ADD mistox.sql /docker-entrypoint-initdb.d
ADD postalcodes.csv /var/lib/mysql-files/postalcodes.csv
EXPOSE 3306
+23
View File
@@ -183,6 +183,29 @@ CREATE TABLE IF NOT EXISTS `JobListingSkill` (
FOREIGN KEY (`JobListingID`) REFERENCES `JobListing`(`ID`) ON DELETE CASCADE
) 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
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": [
{
"glob": "**/*",
"input": "public"
"input": "src/assets"
}
],
"styles": [
+3 -1
View File
@@ -4,6 +4,7 @@ import { Authentication } from './services/Authentication';
import { CommonModule, Location } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { isDevMode } from '@angular/core';
import { TranslationService } from './services/translations';
@Component({
selector: 'app-root',
@@ -16,7 +17,8 @@ export class App {
devMode: boolean = false;
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.route.queryParams.subscribe(params => {
this.loginToken = params['LoginToken'];
@@ -7,9 +7,9 @@
<h1>{{ application.rating }}</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>
@@ -6,6 +6,7 @@ import { Title } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { Authentication } from 'app/services/Authentication';
import { Application } from 'app/models/Application';
import { TranslationService } from 'app/services/translations';
@Component({
selector: 'App-Viewer',
@@ -18,7 +19,7 @@ export class AppViewerComponent {
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");
if (!auth.isLoggedIn){
router.navigate(["/"]);
@@ -46,7 +46,7 @@
<div class="center-text">
<h1>{{ listing.title }}</h1>
</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/editor']" [queryParams]="{ JobID: listing.id }" >EDIT LISTING</button>
<button (click)="RemoveJobListing(listing.id!)">DELETE LISTING</button>
@@ -109,3 +109,13 @@ form {
height: 40px;
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="content-frame">
<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)="nextStep()">Next</button>
</div>
@@ -168,3 +168,9 @@
</div>
</div>
</form>
@if (ErrorMsg != ""){
<div class="error-window">
<h1>{{ ErrorMsg }}</h1>
</div>
}
@@ -62,9 +62,12 @@ export class CompanyEditorComponent {
validateEmail(input: string){
let result = this.validator.validateEmail(input);
if (result[0]){
this.newListing.email = result[1];
}
validateURL(input: string){
let result = this.validator.validateURL(input);
this.newListing.websiteURL = result[1];
}
@HostListener('window:keydown', ['$event'])
@@ -146,6 +149,13 @@ export class CompanyEditorComponent {
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)){
this.ErrorMsg = "Logo is blank";
this.focusFrame(2, 0);
@@ -158,8 +168,9 @@ export class CompanyEditorComponent {
return;
}
if (this.validator.validateEmail(company.email)[0]){
this.ErrorMsg = "Email is invalid";
var emailIsValid = this.validator.validateEmail(company.email);
if (emailIsValid[0] == false){
this.ErrorMsg = emailIsValid[1];
this.focusFrame(3, 0);
return;
}
@@ -170,7 +181,7 @@ export class CompanyEditorComponent {
return;
}
if (this.validator.validatePhoneNumber(company.phone)[0]){
if (this.validator.validatePhoneNumber(company.phone)[0] == false){
this.ErrorMsg = "Phone number is invalid";
this.focusFrame(3, 1);
return;
@@ -2,7 +2,7 @@
<div class="center-frame center-text border">
<h1>Bored Careers</h1>
<h2>The Anti-AI Job Board</h2>
<h2>{{ strings.translate("home", "s subTitle") }}</h2>
</div>
<div class="content-frame border">
@@ -4,6 +4,8 @@ import { FormsModule } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { TranslationService } from 'app/services/translations';
import { Observable } from 'rxjs';
@Component({
selector: 'main-home',
@@ -13,8 +15,12 @@ import { CommonModule } from '@angular/common';
})
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.Loaded$ = this.strings.changed$;
};
}
@@ -58,10 +58,13 @@
}
.content-desc {
border: solid 1px red;
border-radius: 5px;
padding: 20px;
border-radius: 20px;
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 {
@@ -83,9 +86,75 @@
}
.job-timestamp {
display: flex;
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;
}
.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) {
<div class="job-details">
<div class="job-timestamp">
<h1>Opened: {{ selectedJob.createdTime }}</h1>
<h1>Modified: {{ selectedJob.modifiedTime }}</h1>
</div>
@if (selectedJob.isDeleted){
<div class="job-warning">
<h2>THIS JOB POSTING IS CLOSED</h2>
</div>
}
<div class="center-item">
<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>
<h1>{{ selectedJob.remote }}</h1>
<div class="detail-block">
<h1>In Office:</h1>
@if (selectedJob.remote){
<h2>Remote</h2>
} @else {
<h2>On-Site</h2>
}
</div>
<h1>{{ selectedJob.salaryMin }}</h1>
<h1>{{ selectedJob.salaryMax }}</h1>
<div class="detail-block">
<h1>Pay Range:</h1>
<h2>{{ selectedJob.salaryMin }} - {{ selectedJob.salaryMax }}</h2>
</div>
<h1>{{ selectedJob.city }}</h1>
<h1>{{ selectedJob.stateOrRegion }}</h1>
<h1>{{ selectedJob.country }}</h1>
<h1>{{ selectedJob.postalCode }}</h1>
@if (!selectedJob.remote){
<div class="detail-block">
<h1>Location:</h1>
<h2>{{ selectedJob.city }}, {{ selectedJob.stateOrRegion }} {{ selectedJob.country }} {{ selectedJob.postalCode }}</h2>
</div>
}
<div>
<h1>Required Skills</h1>
<div class="detail-block">
<h1>Required Skills:</h1>
@for(skill of selectedJob.skills; track skill.trackUUID){
<div>
<div class="skill-combo">
<div class="nobreak">
<h1>{{ skill.name }}</h1>
<h1>{{ skill.description }}</h1>
</div>
<div class="nobreak">
<h2>{{ skill.description }}</h2>
</div>
</div>
}
</div>
<h1>{{ selectedJob.description }}</h1>
<div>
@for(resume of myResumes; track resume.trackUUID){
<div>
<button type="button" (click)="applyWithResume(resume)">APPLY USING RESUME: {{ resume.name }}</button>
<div class="job-timestamp">
<span>Opened: {{ selectedJob.createdTime }} | Last Updated: {{ selectedJob.modifiedTime }}</span>
</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>
@@ -5,7 +5,6 @@
</div>
<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)" />
<button type="button" (click)="PrintResume()">Print</button>
</div>
<div class="paper">
@@ -140,7 +139,7 @@
<div class="resume-sub-section flex-two-row">
<div>
<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>
<textarea [name]="'certdescription' + cert.trackUUID" [(ngModel)]="cert.description" placeholder="Description"></textarea>
</div>
@@ -156,7 +155,7 @@
<div class="resume-sub-section flex-two-row">
<div>
<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>
<textarea [name]="'projdescription' + proj.trackUUID" [(ngModel)]="proj.description" placeholder="Description"></textarea>
</div>
@@ -90,32 +90,22 @@ export class ResumesEditorComponent {
validatePhone(input: string){
let result = this.validator.validatePhoneNumber(input);
if (result[0]){
this.resume.phoneNumber = result[1];
}
}
validateEmail(input: string){
let result = this.validator.validateEmail(input);
if (result[0]){
this.resume.email = result[1];
}
validateCertURL(input: ResumeCertification){
let result = this.validator.validateURL(input.verificationURL);
input.verificationURL = result[1];
}
PrintResume(){
const divToPrint = document.getElementsByClassName("paper")[0];
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;
validateProjectURL(input: ResumeProject){
let result = this.validator.validateURL(input.url);
input.url = result[1];
}
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] {
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);
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"
}
}