final touches
All checks were successful
ci / docker_image (push) Successful in 1m37s
ci / deploy (push) Successful in 16s

This commit is contained in:
Drake Marino 2025-01-30 22:12:08 -06:00
parent fa14fe0496
commit 5eca1635f5
11 changed files with 206 additions and 26 deletions

View File

@ -318,11 +318,6 @@ h2 {
@apply text-2xl @apply text-2xl
} }
/*.tooltip {*/
/* position: relative;*/
/* display: inline-block;*/
/*}*/
/* Tooltip text */ /* Tooltip text */
.tooltip-text { .tooltip-text {
visibility: hidden; visibility: hidden;

View File

@ -1,7 +1,7 @@
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import sql from '$lib/db/db.server'; import sql from '$lib/db/db.server';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { saveAvatar } from '$lib/index.server'; import { saveAvatar, saveLogo } from '$lib/index.server';
import { import {
EmploymentType, EmploymentType,
type User, type User,
@ -281,6 +281,8 @@ export async function createCompany(company: Company): Promise<number> {
RETURNING id; RETURNING id;
`; `;
await saveLogo(company);
return response[0].id; return response[0].id;
} }
@ -616,7 +618,7 @@ export async function getPosting(id: number): Promise<Posting> {
return posting; return posting;
} }
export async function getPostingFullData(id: number): Promise<Posting> { export async function getPostingWithCompanyUser(id: number): Promise<Posting> {
const data = await sql` const data = await sql`
WITH company_data AS ( WITH company_data AS (
SELECT SELECT
@ -715,15 +717,36 @@ export async function deleteApplicationWithUser(
} }
export async function getApplications(postingId: number): Promise<Application[]> { export async function getApplications(postingId: number): Promise<Application[]> {
const applications = await sql<Application[]>` const data = await sql`
SELECT id, posting_id AS "postingId", user_id AS "userId", candidate_statement AS "candidateStatement", created_at AS "createdAt" SELECT
FROM applications a.id,
WHERE posting_id = ${postingId}; a.candidate_statement AS "candidateStatement",
a.created_at AS "createdAt",
u.id AS "userId",
u.username,
u.email,
u.phone,
u.full_name AS "fullName"
FROM applications a
JOIN users u ON a.user_id = u.id
WHERE a.posting_id = ${postingId};
`; `;
applications.forEach((application) => { data.forEach((application) => {
application.createdAt = new Date(application.createdAt); application.createdAt = new Date(application.createdAt);
application.user = {
id: application.userId,
username: application.username,
email: application.email,
phone: application.phone,
fullName: application.fullName
};
delete application.userId;
delete application.username;
delete application.email;
delete application.phone;
delete application.fullName;
}); });
return applications; return <Application[]>(<unknown>data);
} }

View File

@ -3,7 +3,7 @@ import path from 'path';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { type Cookies, error } from '@sveltejs/kit'; import { type Cookies, error } from '@sveltejs/kit';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import type { User } from '$lib/types'; import type { Company, User } from '$lib/types';
// TODO: Handle saving custom avatar uploads // TODO: Handle saving custom avatar uploads
export async function saveAvatar(user: User): Promise<void> { export async function saveAvatar(user: User): Promise<void> {
@ -14,6 +14,14 @@ export async function saveAvatar(user: User): Promise<void> {
fs.writeFileSync(filePath, avatar); fs.writeFileSync(filePath, avatar);
} }
export async function saveLogo(company: Company): Promise<void> {
const url = `https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(company.name!)}`;
const response = await fetch(url, { headers: { accept: 'image/svg+xml' } });
const avatar = await response.text();
const filePath = path.join('static', 'uploads', 'logos', `${company.id}.svg`);
fs.writeFileSync(filePath, avatar);
}
// TODO: change to return null instead of -1 // TODO: change to return null instead of -1
export function getUserPerms(cookies: Cookies): number { export function getUserPerms(cookies: Cookies): number {
if (process.env.JWT_SECRET === undefined) { if (process.env.JWT_SECRET === undefined) {

View File

@ -65,4 +65,5 @@ export interface Application {
postingTitle: string | null; postingTitle: string | null;
candidateStatement: string; candidateStatement: string;
createdAt: Date; createdAt: Date;
user: User | null;
} }

View File

@ -1,8 +1,8 @@
import { getPosting, getPostingFullData } from '$lib/db/index.server'; import { getPosting, getPostingWithCompanyUser } from '$lib/db/index.server';
import { error, json } from '@sveltejs/kit'; import { error, json } from '@sveltejs/kit';
export async function GET({ url }) { export async function GET({ url }) {
const id = url.searchParams.get('id'); const id = url.searchParams.get('id');
if (!id) return new Response(error(400, 'No id provided')); if (!id) return new Response(error(400, 'No id provided'));
return json(await getPostingFullData(parseInt(id))); return json(await getPostingWithCompanyUser(parseInt(id)));
} }

View File

@ -12,9 +12,9 @@
day: 'numeric' day: 'numeric'
}; };
function logoFallback(e: Event, posting: Posting) { function logoFallback(e: Event, posting: Posting | undefined) {
(e.target as HTMLImageElement).src = (e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(posting.company.name || 'COMPANY')}`; `https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(posting?.company.name || 'COMPANY')}`;
} }
async function fetchDetails(id: number) { async function fetchDetails(id: number) {

View File

@ -1,8 +1,8 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { getPostingFullData, getPostings } from '$lib/db/index.server'; import { getPostingWithCompanyUser, getPostings } from '$lib/db/index.server';
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params }) => {
return { return {
posting: await getPostingFullData(parseInt(params.posting)) posting: await getPostingWithCompanyUser(parseInt(params.posting))
}; };
}; };

View File

@ -1,16 +1,20 @@
import { type Actions, error, fail, redirect } from '@sveltejs/kit'; import { type Actions, error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { createApplication, getPostingFullData, getUserWithCompany } from '$lib/db/index.server'; import {
import { getUserId, getUserPerms } from '$lib/index.server'; createApplication,
getPostingWithCompanyUser,
getUserWithCompany
} from '$lib/db/index.server';
import { getUserCompanyId, getUserId, getUserPerms } from '$lib/index.server';
import { PERMISSIONS } from '$lib/consts'; import { PERMISSIONS } from '$lib/consts';
import type { Application } from '$lib/types'; import type { Application } from '$lib/types';
export const load: PageServerLoad = async ({ params, cookies }) => { export const load: PageServerLoad = async ({ params, cookies }) => {
const id = parseInt(params.posting); const id = parseInt(params.posting);
const perms = getUserPerms(cookies); const perms = getUserPerms(cookies);
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_USERS) > 0) { if (perms >= 0 && (perms & PERMISSIONS.APPLY_FOR_JOBS) > 0) {
return { return {
posting: await getPostingFullData(id) posting: await getPostingWithCompanyUser(id)
}; };
} }
error(403, 'Unauthorized'); error(403, 'Unauthorized');

View File

@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import { employmentTypeDisplayName, userState } from '$lib/shared.svelte'; import { employmentTypeDisplayName } from '$lib/shared.svelte';
import type { Posting } from '$lib/types'; import type { Posting } from '$lib/types';
import { PERMISSIONS } from '$lib/consts';
const dateFormatOptions: Intl.DateTimeFormatOptions = { const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric', year: 'numeric',

View File

@ -0,0 +1,39 @@
import type { PageServerLoad } from './$types';
import { getUserCompanyId, getUserPerms } from '$lib/index.server';
import { PERMISSIONS } from '$lib/consts';
import {
deleteApplication,
getApplications,
getPostingWithCompanyUser
} from '$lib/db/index.server';
import { type Actions, error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params, cookies }) => {
const id = parseInt(params.posting);
const perms = getUserPerms(cookies);
if (
perms >= 0 &&
((perms & PERMISSIONS.MANAGE_POSTINGS) > 0 ||
((perms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies) === id))
) {
return {
applications: await getApplications(id)
};
}
error(403, 'Unauthorized');
};
export const actions: Actions = {
delete: async ({ url, cookies }) => {
const id = parseInt(url.searchParams.get('id')!);
const perms = getUserPerms(cookies);
if (
perms >= 0 &&
((perms & PERMISSIONS.MANAGE_POSTINGS) > 0 ||
((perms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies) === id))
) {
return await deleteApplication(id);
}
error(403, 'Unauthorized');
}
};

View File

@ -0,0 +1,111 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import { employmentTypeDisplayName } from '$lib/shared.svelte';
import type { Application, Posting } from '$lib/types';
import { onMount } from 'svelte';
const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric'
};
function logoFallback(e: Event, application: Application) {
(e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(application.user.fullName || 'USER')}`;
}
onMount(() => {
const acc = document.getElementsByClassName('accordion');
for (let i = 0; i < acc.length; i++) {
acc[i].addEventListener('click', function (this: HTMLElement, event: Event) {
this.classList.toggle('active');
this.children[1].innerHTML = this.classList.contains('active')
? 'arrow_drop_up'
: 'arrow_drop_down';
/* Toggle between hiding and showing the active panel */
let panel = this.nextElementSibling as HTMLElement;
if (panel.style.display === 'block') {
panel.style.display = 'none';
} else {
panel.style.display = 'block';
}
});
}
});
let { data, form }: PageProps = $props();
</script>
<div class="content">
<div class="elevated separator-borders m-4 rounded">
<div class="flex place-content-between">
<div class="p-3 font-semibold">
Application Management (Total: {data.applications?.length || 0})
</div>
</div>
<div>
{#each data.applications as application}
<button class="top-border accordion flex place-content-between">
<div>
<img
class="m-2 inline-block rounded"
src="/uploads/avatars/{application.user?.id || 'default'}.svg"
alt="Company Logo"
height="32"
width="32"
onerror={(e) => logoFallback(e, application)}
/>
<div class="inline-block py-1 text-left align-top">
<div class="font-semibold">
{application.user?.fullName}
</div>
<div>
{application.user?.username}
</div>
</div>
</div>
<div class="material-symbols-outlined p-4">arrow_drop_down</div>
</button>
<div class="panel hidden p-2">
<div class="flex justify-between">
<div class="inline-block">
<div class="inline-block pr-4 align-top">
{#if application.user?.email}
<p>Email: {application.user.email}</p>
{/if}
{#if application.user?.phone}
<p>Phone: {application.user.email}</p>
{/if}
{#if application.createdAt}
<p>
Applied: {application.createdAt.toLocaleDateString('en-US', dateFormatOptions)}
</p>
{/if}
</div>
<div class="left-border inline-block pl-4">
<h2>Candidate Statement</h2>
<h3 class="low-emphasis-text">
Why do you believe you are the best fit for this role?
</h3>
<p class="whitespace-pre-wrap">{application.candidateStatement}</p>
</div>
</div>
<form method="POST">
<button
class="material-symbols-outlined danger-color inline-block p-1 align-top"
type="submit"
formaction="?/delete&id={application.id}"
>
delete
</button>
</form>
</div>
</div>
{/each}
</div>
</div>
</div>