Skip to content

Commit f3163b0

Browse files
authored
Merge pull request #66 from reqcore-inc/fix/upload-cv-&-cover-letter
fix: Fixes the public application form to show file upload and cover letter, additionally imrpove the multi step form to simplify the process
2 parents f7f79e8 + 0297943 commit f3163b0

26 files changed

Lines changed: 6311 additions & 178 deletions

.github/instructions/snyk_rules.instructions.md

Lines changed: 0 additions & 14 deletions
This file was deleted.

.github/workflows/e2e-tests.yml

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
e2e:
1818
name: Playwright E2E
1919
runs-on: ubuntu-latest
20-
timeout-minutes: 20
20+
timeout-minutes: 30
2121

2222
services:
2323
postgres:
@@ -34,12 +34,13 @@ jobs:
3434
--health-timeout 5s
3535
--health-retries 10
3636
37+
3738
env:
3839
DATABASE_URL: postgresql://reqcore:reqcore-ci@localhost:5432/reqcore
3940
BETTER_AUTH_SECRET: ci-test-secret-that-is-at-least-32-chars-long
4041
BETTER_AUTH_URL: http://localhost:3000
4142
NUXT_PUBLIC_SITE_URL: http://localhost:3000
42-
# S3 is optional — tests don't upload files, but the env vars must exist
43+
# MinIO provides S3-compatible file storage for E2E tests
4344
S3_ENDPOINT: http://localhost:9000
4445
S3_ACCESS_KEY: minioadmin
4546
S3_SECRET_KEY: minioadmin
@@ -48,6 +49,21 @@ jobs:
4849
S3_FORCE_PATH_STYLE: "true"
4950

5051
steps:
52+
- name: Start MinIO
53+
run: |
54+
docker run -d \
55+
--name minio-ci \
56+
-p 9000:9000 \
57+
-e MINIO_ROOT_USER=minioadmin \
58+
-e MINIO_ROOT_PASSWORD=minioadmin \
59+
minio/minio:latest server /data
60+
timeout 30 sh -c 'until curl -sf http://localhost:9000/minio/health/live; do sleep 2; done'
61+
AWS_ACCESS_KEY_ID=minioadmin \
62+
AWS_SECRET_ACCESS_KEY=minioadmin \
63+
aws s3 mb s3://reqcore \
64+
--endpoint-url http://localhost:9000 \
65+
--region us-east-1
66+
5167
- name: Checkout
5268
uses: actions/checkout@v4
5369

@@ -125,7 +141,9 @@ jobs:
125141

126142
- name: Generate Allure report
127143
if: ${{ !cancelled() }}
128-
run: npx allure generate allure-results -o allure-report
144+
run: |
145+
mkdir -p allure-results
146+
npx allure generate allure-results -o allure-report
129147
130148
- name: Upload Allure results
131149
uses: actions/upload-artifact@v4

app/composables/useJob.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export function useJob(id: MaybeRefOrGetter<string>) {
2424
location: string
2525
type: 'full_time' | 'part_time' | 'contract' | 'internship'
2626
status: 'draft' | 'open' | 'closed' | 'archived'
27+
requireResume: boolean
28+
requireCoverLetter: boolean
2729
}>) {
2830
try {
2931
const updated = await $fetch(`/api/jobs/${jobId.value}`, {

app/composables/useJobs.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export function useJobs(options?: {
2828
description?: string
2929
location?: string
3030
type?: 'full_time' | 'part_time' | 'contract' | 'internship'
31+
requireResume?: boolean
32+
requireCoverLetter?: boolean
3133
}) {
3234
try {
3335
const created = await $fetch('/api/jobs', {

app/pages/dashboard/jobs/[id]/application-form.vue

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { FileText, Link2, ClipboardCopy } from 'lucide-vue-next'
2+
import { FileText, Link2, ClipboardCopy, Check } from 'lucide-vue-next'
33
44
definePageMeta({
55
layout: 'dashboard',
@@ -9,7 +9,7 @@ definePageMeta({
99
const route = useRoute()
1010
const jobId = route.params.id as string
1111
12-
const { job, status: fetchStatus, error } = useJob(jobId)
12+
const { job, status: fetchStatus, error, updateJob } = useJob(jobId)
1313
1414
useSeoMeta({
1515
title: computed(() =>
@@ -39,6 +39,38 @@ async function copyApplicationLink() {
3939
alert(applicationUrl.value)
4040
}
4141
}
42+
43+
// ─────────────────────────────────────────────
44+
// Application requirements (resume / cover letter)
45+
// ─────────────────────────────────────────────
46+
47+
const requireResume = ref(false)
48+
const requireCoverLetter = ref(false)
49+
const isSavingRequirements = ref(false)
50+
const requirementsSaved = ref(false)
51+
const requirementsError = ref<string | null>(null)
52+
53+
// Sync with fetched job data
54+
watch(job, (j) => {
55+
if (j) {
56+
requireResume.value = j.requireResume ?? false
57+
requireCoverLetter.value = j.requireCoverLetter ?? false
58+
}
59+
}, { immediate: true })
60+
61+
async function saveRequirements() {
62+
isSavingRequirements.value = true
63+
requirementsError.value = null
64+
try {
65+
await updateJob({ requireResume: requireResume.value, requireCoverLetter: requireCoverLetter.value })
66+
requirementsSaved.value = true
67+
setTimeout(() => { requirementsSaved.value = false }, 2000)
68+
} catch (err: any) {
69+
requirementsError.value = err?.data?.statusMessage ?? 'Failed to save requirements.'
70+
} finally {
71+
isSavingRequirements.value = false
72+
}
73+
}
4274
</script>
4375

4476
<template>
@@ -96,6 +128,69 @@ async function copyApplicationLink() {
96128
The application link will be available when this job is published (status: <strong>open</strong>).
97129
</div>
98130

131+
<!-- Application Requirements -->
132+
<div class="rounded-lg border border-surface-200 dark:border-surface-800 bg-white dark:bg-surface-900 p-5 mb-6">
133+
<h2 class="text-sm font-semibold text-surface-700 dark:text-surface-300 mb-1">Application requirements</h2>
134+
<p class="text-xs text-surface-400 dark:text-surface-500 mb-4">
135+
Choose what candidates must provide when applying.
136+
</p>
137+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
138+
<button
139+
type="button"
140+
class="relative flex items-center gap-3 p-4 rounded-xl border text-left transition-colors"
141+
:class="requireResume
142+
? 'border-brand-300 dark:border-brand-700 bg-brand-50/70 dark:bg-brand-950/30'
143+
: 'border-surface-200 dark:border-surface-800 hover:bg-surface-50 dark:hover:bg-surface-800/50'"
144+
:aria-pressed="requireResume"
145+
@click="requireResume = !requireResume"
146+
>
147+
<span
148+
v-if="requireResume"
149+
class="absolute top-3 right-3 inline-flex items-center justify-center size-5 rounded-full bg-brand-600 text-white"
150+
aria-hidden="true"
151+
>
152+
<Check class="size-3" />
153+
</span>
154+
<div>
155+
<span class="block text-sm font-medium text-surface-900 dark:text-surface-100">Require resume/CV</span>
156+
<span class="text-xs text-surface-500">Candidates must upload a file.</span>
157+
</div>
158+
</button>
159+
<button
160+
type="button"
161+
class="relative flex items-center gap-3 p-4 rounded-xl border text-left transition-colors"
162+
:class="requireCoverLetter
163+
? 'border-brand-300 dark:border-brand-700 bg-brand-50/70 dark:bg-brand-950/30'
164+
: 'border-surface-200 dark:border-surface-800 hover:bg-surface-50 dark:hover:bg-surface-800/50'"
165+
:aria-pressed="requireCoverLetter"
166+
@click="requireCoverLetter = !requireCoverLetter"
167+
>
168+
<span
169+
v-if="requireCoverLetter"
170+
class="absolute top-3 right-3 inline-flex items-center justify-center size-5 rounded-full bg-brand-600 text-white"
171+
aria-hidden="true"
172+
>
173+
<Check class="size-3" />
174+
</span>
175+
<div>
176+
<span class="block text-sm font-medium text-surface-900 dark:text-surface-100">Ask for cover letter</span>
177+
<span class="text-xs text-surface-500">Candidates can write a cover letter.</span>
178+
</div>
179+
</button>
180+
</div>
181+
<button
182+
type="button"
183+
:disabled="isSavingRequirements"
184+
class="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50 transition-colors"
185+
@click="saveRequirements"
186+
>
187+
{{ requirementsSaved ? 'Saved!' : isSavingRequirements ? 'Saving…' : 'Save requirements' }}
188+
</button>
189+
<p v-if="requirementsError" class="mt-2 text-xs text-danger-600 dark:text-danger-400">
190+
{{ requirementsError }}
191+
</p>
192+
</div>
193+
99194
<!-- Application Form Questions -->
100195
<div class="rounded-lg border border-surface-200 dark:border-surface-800 bg-white dark:bg-surface-900 p-5">
101196
<div class="flex items-center gap-2 mb-3">

app/pages/dashboard/jobs/[id]/index.vue

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import {
33
ArrowLeft, ArrowRight, Briefcase, Clock, Hash, UserRound, Mail, MessageSquare,
44
FileText, Paperclip, Download, Eye, Phone, Search, ExternalLink,
5-
UserPlus, Pencil, Trash2, MoreHorizontal, Globe, ChevronDown,
5+
UserPlus, Pencil, Trash2, MoreHorizontal, Globe, ChevronDown, X,
66
} from 'lucide-vue-next'
77
import { z } from 'zod'
88
import { usePreviewReadOnly } from '~/composables/usePreviewReadOnly'
@@ -353,6 +353,11 @@ function goToNextCard() {
353353
}
354354
355355
function handleKeyNavigation(event: KeyboardEvent) {
356+
if (event.key === 'Escape' && showDocPreview.value) {
357+
closeDocPreview()
358+
return
359+
}
360+
356361
if ((event.target as HTMLElement)?.tagName === 'INPUT' || (event.target as HTMLElement)?.tagName === 'TEXTAREA' || (event.target as HTMLElement)?.tagName === 'SELECT') return
357362
358363
if (event.key === 'ArrowUp') {
@@ -557,6 +562,41 @@ onBeforeUnmount(() => {
557562
const isLoading = computed(() => {
558563
return jobFetchStatus.value === 'pending' || appFetchStatus.value === 'pending'
559564
})
565+
566+
// ─────────────────────────────────────────────
567+
// Document preview
568+
// ─────────────────────────────────────────────
569+
570+
const { getPreviewUrl } = useDocuments()
571+
572+
const showDocPreview = ref(false)
573+
const docPreviewUrl = ref<string | null>(null)
574+
const docPreviewFilename = ref('')
575+
const docPreviewMimeType = ref('')
576+
const docPreviewDocId = ref<string | null>(null)
577+
578+
const isDocPreviewPdf = computed(() => docPreviewMimeType.value === 'application/pdf')
579+
580+
function handleDocPreview(doc: SwipeDocument) {
581+
if (doc.mimeType !== 'application/pdf') {
582+
// Non-PDFs: fall back to download
583+
window.open(`/api/documents/${doc.id}/download`, '_blank')
584+
return
585+
}
586+
docPreviewDocId.value = doc.id
587+
docPreviewFilename.value = doc.originalFilename
588+
docPreviewMimeType.value = doc.mimeType
589+
docPreviewUrl.value = getPreviewUrl(doc.id)
590+
showDocPreview.value = true
591+
}
592+
593+
function closeDocPreview() {
594+
showDocPreview.value = false
595+
docPreviewUrl.value = null
596+
docPreviewFilename.value = ''
597+
docPreviewMimeType.value = ''
598+
docPreviewDocId.value = null
599+
}
560600
</script>
561601

562602
<template>
@@ -1068,15 +1108,13 @@ const isLoading = computed(() => {
10681108
</div>
10691109
</div>
10701110
<div class="flex items-center gap-2">
1071-
<a
1072-
:href="`/api/documents/${doc.id}/preview`"
1073-
target="_blank"
1074-
rel="noopener noreferrer"
1111+
<button
10751112
class="inline-flex items-center gap-1.5 rounded-lg border border-surface-200 px-3 py-1.5 text-xs font-medium text-surface-600 hover:bg-surface-50 hover:border-surface-300 dark:border-surface-700 dark:text-surface-300 dark:hover:bg-surface-800 dark:hover:border-surface-600 transition-all duration-150"
1113+
@click="handleDocPreview(doc)"
10761114
>
10771115
<Eye class="size-3.5" />
10781116
Preview
1079-
</a>
1117+
</button>
10801118
<a
10811119
:href="`/api/documents/${doc.id}/download`"
10821120
class="inline-flex items-center gap-1.5 rounded-lg border border-surface-200 px-3 py-1.5 text-xs font-medium text-surface-600 hover:bg-surface-50 hover:border-surface-300 dark:border-surface-700 dark:text-surface-300 dark:hover:bg-surface-800 dark:hover:border-surface-600 transition-all duration-150"
@@ -1250,5 +1288,60 @@ const isLoading = computed(() => {
12501288
@close="showApplyModal = false"
12511289
@created="handleCandidateApplied"
12521290
/>
1291+
1292+
<!-- Document Preview Modal -->
1293+
<Teleport to="body">
1294+
<div v-if="showDocPreview" class="fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
1295+
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="closeDocPreview" />
1296+
<div class="relative flex flex-col bg-white dark:bg-surface-900 rounded-2xl shadow-2xl shadow-surface-900/10 dark:shadow-black/30 ring-1 ring-surface-200/80 dark:ring-surface-700/60 w-full max-w-4xl" style="height: calc(100vh - 3rem);">
1297+
<!-- Header -->
1298+
<div class="flex items-center justify-between px-5 py-3 border-b border-surface-200/80 dark:border-surface-800/60 shrink-0">
1299+
<div class="flex items-center gap-2.5 min-w-0">
1300+
<FileText class="size-4 text-surface-400 shrink-0" />
1301+
<span class="text-sm font-medium text-surface-800 dark:text-surface-100 truncate">{{ docPreviewFilename }}</span>
1302+
</div>
1303+
<div class="flex items-center gap-2 shrink-0 ml-4">
1304+
<a
1305+
v-if="docPreviewDocId"
1306+
:href="`/api/documents/${docPreviewDocId}/download`"
1307+
class="inline-flex items-center gap-1.5 rounded-lg border border-surface-200 px-2.5 py-1.5 text-xs font-medium text-surface-600 hover:bg-surface-50 hover:border-surface-300 dark:border-surface-700 dark:text-surface-300 dark:hover:bg-surface-800 transition-all duration-150"
1308+
>
1309+
<Download class="size-3.5" />
1310+
Download
1311+
</a>
1312+
<button
1313+
class="rounded-lg p-1.5 text-surface-500 hover:text-surface-700 hover:bg-surface-100 dark:hover:text-surface-300 dark:hover:bg-surface-800 transition-colors"
1314+
title="Close"
1315+
@click="closeDocPreview"
1316+
>
1317+
<X class="size-4" />
1318+
</button>
1319+
</div>
1320+
</div>
1321+
<!-- PDF viewer -->
1322+
<iframe
1323+
v-if="docPreviewUrl && isDocPreviewPdf"
1324+
:src="docPreviewUrl"
1325+
class="flex-1 w-full rounded-b-2xl min-h-0"
1326+
title="Document preview"
1327+
/>
1328+
<!-- Non-PDF fallback -->
1329+
<div v-else class="flex-1 flex items-center justify-center p-8 text-center">
1330+
<div>
1331+
<FileText class="size-12 text-surface-300 dark:text-surface-600 mx-auto mb-3" />
1332+
<p class="text-sm font-medium text-surface-600 dark:text-surface-300">Preview not available for this file type</p>
1333+
<a
1334+
v-if="docPreviewDocId"
1335+
:href="`/api/documents/${docPreviewDocId}/download`"
1336+
class="mt-3 inline-flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"
1337+
>
1338+
<Download class="size-3.5" />
1339+
Download instead
1340+
</a>
1341+
</div>
1342+
</div>
1343+
</div>
1344+
</div>
1345+
</Teleport>
12531346
</div>
12541347
</template>

0 commit comments

Comments
 (0)