Files
root 9e2f858343
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m22s
fix job positions
2026-09-03 13:01:36 -04:00

535 lines
21 KiB
PHP

<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ApplicationModel;
use App\Models\JobPositionModel;
use App\Models\JobTemplateModel;
use App\Models\JobTemplateVersionModel;
use App\Services\EmailService;
use App\Services\PhoneFormatterService;
class JobPostingController extends BaseController
{
private const ADMIN_FILTER_ROUTE = 'administrator/job-postings';
private array $positionStatuses = ['draft', 'open', 'closed', 'filled'];
private array $applicationStatuses = ['new', 'reviewed', 'contacted', 'rejected', 'hired'];
protected JobTemplateModel $templates;
protected JobTemplateVersionModel $templateVersions;
protected JobPositionModel $positions;
protected ApplicationModel $applications;
public function __construct()
{
helper(['form', 'url', 'text']);
$this->templates = new JobTemplateModel();
$this->templateVersions = new JobTemplateVersionModel();
$this->positions = new JobPositionModel();
$this->applications = new ApplicationModel();
}
public function publicIndex()
{
return view('careers', [
'positions' => $this->positions->openPositions(),
]);
}
public function show(string $positionId)
{
$position = $this->positions->where('position_id', $positionId)->where('status', 'open')->first();
if (!$position) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Position not found');
}
return view('jobs/show', ['position' => $position]);
}
public function trackDetailsClick(string $positionId)
{
$position = $this->positions->where('position_id', $positionId)->where('status', 'open')->first();
if (!$position) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Position not found');
}
$this->positions->recordDetailsClick($positionId);
return redirect()->to(site_url('careers/' . rawurlencode($positionId)));
}
public function apply(string $positionId)
{
$position = $this->positions->where('position_id', $positionId)->where('status', 'open')->first();
if (!$position) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Position not found');
}
return view('jobs/apply', ['position' => $position]);
}
public function submitApplication(string $positionId)
{
$position = $this->positions->where('position_id', $positionId)->where('status', 'open')->first();
if (!$position) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Position not found');
}
$rules = [
'first_name' => 'required|regex_match[/^[a-zA-Z\s-]+$/]|min_length[2]|max_length[30]',
'last_name' => 'required|regex_match[/^[a-zA-Z\s-]+$/]|min_length[2]|max_length[30]',
'email' => 'required|valid_email|max_length[50]',
'phone' => 'required|regex_match[/^[\d\s\-\(\)\.]+$/]|min_length[10]|max_length[20]',
'resume' => 'uploaded[resume]|max_size[resume,5120]|ext_in[resume,pdf,doc,docx]',
];
$messages = [
'first_name' => [
'regex_match' => 'First name may only contain letters, spaces, and dashes.',
'min_length' => 'First name must be at least 2 characters.',
'max_length' => 'First name must be 30 characters or fewer.',
],
'last_name' => [
'regex_match' => 'Last name may only contain letters, spaces, and dashes.',
'min_length' => 'Last name must be at least 2 characters.',
'max_length' => 'Last name must be 30 characters or fewer.',
],
'email' => [
'valid_email' => 'Please enter a valid email address.',
'max_length' => 'Email must be 50 characters or fewer.',
],
'phone' => [
'regex_match' => 'Please enter a valid 10-digit phone number, for example 123-456-7890.',
'min_length' => 'Please enter a valid 10-digit phone number, for example 123-456-7890.',
'max_length' => 'Please enter a valid 10-digit phone number, for example 123-456-7890.',
],
];
if (!$this->validate($rules, $messages)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$formattedPhone = (new PhoneFormatterService())->formatPhoneNumber((string) $this->request->getPost('phone'));
if ($formattedPhone === null) {
return redirect()->back()->withInput()->with('errors', [
'phone' => 'Please enter a valid 10-digit phone number, for example 123-456-7890.',
]);
}
$file = $this->request->getFile('resume');
$uploadDir = WRITEPATH . 'uploads/job_applications';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$newName = $file->getRandomName();
$file->move($uploadDir, $newName);
$relativePath = 'job_applications/' . $newName;
$application = [
'application_id' => $this->newUuid(),
'position_id' => $position['position_id'],
'first_name' => trim((string) $this->request->getPost('first_name')),
'last_name' => trim((string) $this->request->getPost('last_name')),
'email' => trim((string) $this->request->getPost('email')),
'phone' => $formattedPhone,
'resume_file_url' => $relativePath,
'status' => 'new',
'submitted_at' => utc_now(),
];
if (!$this->applications->insert($application)) {
return redirect()->back()->withInput()->with('error', 'Unable to submit application.');
}
$this->sendApplicationEmails($application, $position);
return redirect()->to(site_url('careers/application-received'))->with('success', 'Application submitted.');
}
public function applicationReceived()
{
return view('jobs/received');
}
public function templates()
{
return view('jobs/admin/templates', [
'templates' => $this->templates->orderBy('updated_at', 'DESC')->findAll(),
]);
}
public function newTemplate()
{
return view('jobs/admin/template_form', ['template' => null, 'versions' => []]);
}
public function createTemplate()
{
$payload = $this->templatePayload();
if ($payload === null) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$payload['template_id'] = $this->newUuid();
$payload['version'] = 1;
$payload['is_active'] = 1;
$payload['created_by'] = $this->currentUserId();
if (!$this->templates->insert($payload)) {
return redirect()->back()->withInput()->with('error', 'Unable to create template.');
}
$this->snapshotTemplate($payload);
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('success', 'Template created.');
}
public function editTemplate(string $templateId)
{
$template = $this->templates->find($templateId);
if (!$template) {
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('error', 'Template not found.');
}
return view('jobs/admin/template_form', [
'template' => $template,
'versions' => $this->templateVersions->where('template_id', $templateId)->orderBy('version', 'DESC')->findAll(),
]);
}
public function updateTemplate(string $templateId)
{
$template = $this->templates->find($templateId);
if (!$template) {
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('error', 'Template not found.');
}
$payload = $this->templatePayload();
if ($payload === null) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
if ($this->request->getPost('save_mode') === 'new_version') {
$payload['version'] = ((int) ($template['version'] ?? 1)) + 1;
} else {
$payload['version'] = (int) ($template['version'] ?? 1);
}
if (!$this->templates->update($templateId, $payload)) {
return redirect()->back()->withInput()->with('error', 'Unable to update template.');
}
$payload['template_id'] = $templateId;
$this->snapshotTemplate($payload);
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('success', 'Template updated.');
}
public function archiveTemplate(string $templateId)
{
$this->templates->update($templateId, ['is_active' => 0]);
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates'))->with('success', 'Template archived.');
}
public function restoreTemplateVersion(string $versionId)
{
$version = $this->templateVersions->find($versionId);
if (!$version) {
return redirect()->back()->with('error', 'Template version not found.');
}
$payload = $this->templateVersionPayload($version);
$payload['version'] = (int) $version['version'];
$this->templates->update($version['template_id'], $payload);
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/templates/' . $version['template_id'] . '/edit'))->with('success', 'Template version restored.');
}
public function positions()
{
return view('jobs/admin/positions', [
'positions' => $this->positions->adminPositions(),
]);
}
public function newPosition()
{
$template = null;
$templateId = (string) $this->request->getGet('template_id');
if ($templateId !== '') {
$template = $this->templates->find($templateId);
}
return view('jobs/admin/position_form', [
'position' => $template ? $this->templateVersionPayload($template) + ['template_id' => $templateId, 'status' => 'draft'] : null,
'statuses' => $this->positionStatuses,
'templates' => $this->templates->where('is_active', 1)->orderBy('title', 'ASC')->findAll(),
]);
}
public function createPosition()
{
$payload = $this->positionPayload();
if ($payload === null) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$payload['position_id'] = $this->newUuid();
$payload['posted_by'] = $this->currentUserId();
if ($payload['status'] === 'open') {
$payload['posted_at'] = utc_now();
}
if (!$this->positions->insert($payload)) {
return redirect()->back()->withInput()->with('error', 'Unable to create position.');
}
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/positions'))->with('success', 'Position created.');
}
public function editPosition(string $positionId)
{
$position = $this->positions->find($positionId);
if (!$position) {
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/positions'))->with('error', 'Position not found.');
}
return view('jobs/admin/position_form', [
'position' => $position,
'statuses' => $this->positionStatuses,
'templates' => $this->templates->where('is_active', 1)->orderBy('title', 'ASC')->findAll(),
]);
}
public function updatePosition(string $positionId)
{
$position = $this->positions->find($positionId);
if (!$position) {
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/positions'))->with('error', 'Position not found.');
}
$payload = $this->positionPayload();
if ($payload === null) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
if ($payload['status'] === 'open' && (($position['status'] ?? '') !== 'open' || empty($position['posted_at']))) {
$payload['posted_at'] = utc_now();
}
if (!$this->positions->update($positionId, $payload)) {
return redirect()->back()->withInput()->with('error', 'Unable to update position.');
}
return redirect()->to(site_url(self::ADMIN_FILTER_ROUTE . '/positions'))->with('success', 'Position updated.');
}
public function applications()
{
$builder = $this->applications
->select('applications.*, job_positions.title AS position_title, job_positions.department')
->join('job_positions', 'job_positions.position_id = applications.position_id', 'left');
$status = (string) $this->request->getGet('status');
if (in_array($status, $this->applicationStatuses, true)) {
$builder->where('applications.status', $status);
}
$positionId = (string) $this->request->getGet('position_id');
if ($positionId !== '') {
$builder->where('applications.position_id', $positionId);
}
return view('jobs/admin/applications', [
'applications' => $builder->orderBy('submitted_at', 'DESC')->findAll(),
'positions' => $this->positions->orderBy('title', 'ASC')->findAll(),
'statuses' => $this->applicationStatuses,
'selectedStatus' => $status,
'selectedPosition' => $positionId,
]);
}
public function updateApplication(string $applicationId)
{
$status = (string) $this->request->getPost('status');
if (!in_array($status, $this->applicationStatuses, true)) {
return redirect()->back()->with('error', 'Invalid application status.');
}
if (!$this->applications->update($applicationId, [
'status' => $status,
'admin_notes' => (string) $this->request->getPost('admin_notes'),
])) {
return redirect()->back()->with('error', 'Unable to update application.');
}
$application = $this->applications
->select('applications.*, job_positions.title AS position_title')
->join('job_positions', 'job_positions.position_id = applications.position_id', 'left')
->find($applicationId);
if ($application) {
$this->sendApplicationStatusEmail($application);
}
return redirect()->back()->with('success', 'Application updated.');
}
public function resume(string $applicationId)
{
$application = $this->applications->find($applicationId);
if (!$application) {
return redirect()->back()->with('error', 'Application not found.');
}
$path = WRITEPATH . 'uploads/' . ltrim((string) $application['resume_file_url'], '/');
if (!is_file($path)) {
return redirect()->back()->with('error', 'Resume file not found.');
}
return $this->response->download($path, null);
}
private function templatePayload(): ?array
{
if (!$this->validate(['title' => 'required|max_length[255]'])) {
return null;
}
return $this->sharedPostingPayload();
}
private function positionPayload(): ?array
{
if (!$this->validate([
'title' => 'required|max_length[255]',
'status' => 'required|in_list[draft,open,closed,filled]',
])) {
return null;
}
$payload = $this->sharedPostingPayload();
$payload['template_id'] = $this->request->getPost('template_id') ?: null;
$payload['status'] = (string) $this->request->getPost('status');
return $payload;
}
private function sharedPostingPayload(): array
{
return [
'title' => trim((string) $this->request->getPost('title')),
'description' => trim((string) $this->request->getPost('description')),
'department' => trim((string) $this->request->getPost('department')),
'location' => trim((string) $this->request->getPost('location')),
'employment_type' => trim((string) $this->request->getPost('employment_type')),
'responsibilities' => trim((string) $this->request->getPost('responsibilities')),
'requirements' => trim((string) $this->request->getPost('requirements')),
];
}
private function snapshotTemplate(array $template): void
{
$this->templateVersions
->where('template_id', $template['template_id'])
->where('version', (int) ($template['version'] ?? 1))
->delete();
$snapshot = $this->templateVersionPayload($template);
$snapshot['version_id'] = $this->newUuid();
$snapshot['template_id'] = $template['template_id'];
$snapshot['version'] = (int) ($template['version'] ?? 1);
$snapshot['saved_by'] = $this->currentUserId();
$snapshot['saved_at'] = utc_now();
$this->templateVersions->insert($snapshot);
}
private function templateVersionPayload(array $data): array
{
return [
'title' => (string) ($data['title'] ?? ''),
'description' => (string) ($data['description'] ?? ''),
'department' => (string) ($data['department'] ?? ''),
'location' => (string) ($data['location'] ?? ''),
'employment_type' => (string) ($data['employment_type'] ?? ''),
'responsibilities' => (string) ($data['responsibilities'] ?? ''),
'requirements' => (string) ($data['requirements'] ?? ''),
];
}
private function sendApplicationEmails(array $application, array $position): void
{
$fullName = trim($application['first_name'] . ' ' . $application['last_name']);
$body = view('emails/job_application_confirmation', [
'name' => $fullName,
'position' => $position,
]);
try {
(new EmailService())->send($application['email'], 'Application received: ' . $position['title'], $body, 'general');
} catch (\Throwable $e) {
log_message('error', 'Job application confirmation email failed: ' . $e->getMessage());
}
$adminRecipients = array_unique(array_filter([
trim((string) env('JOBS_ADMIN_EMAIL', '')),
trim((string) env('PRINCIPAL_EMAIL', '')),
], static fn (string $email): bool => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)));
if ($adminRecipients !== []) {
$adminBody = '<p>New application received for <strong>' . esc($position['title']) . '</strong>.</p>'
. '<p>Applicant: ' . esc($fullName) . '<br>Email: ' . esc($application['email']) . '<br>Phone: ' . esc($application['phone']) . '</p>';
$emailService = new EmailService();
try {
foreach ($adminRecipients as $adminEmail) {
$emailService->send($adminEmail, 'New job application: ' . $position['title'], $adminBody, 'general');
}
} catch (\Throwable $e) {
log_message('error', 'Job application admin email failed: ' . $e->getMessage());
}
}
}
private function sendApplicationStatusEmail(array $application): void
{
$email = trim((string) ($application['email'] ?? ''));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return;
}
$status = (string) ($application['status'] ?? '');
$statusLabel = ucfirst(str_replace('_', ' ', $status));
$positionTitle = (string) ($application['position_title'] ?? 'the volunteer position');
$fullName = trim((string) (($application['first_name'] ?? '') . ' ' . ($application['last_name'] ?? '')));
$body = view('emails/job_application_status_update', [
'name' => $fullName !== '' ? $fullName : 'Applicant',
'positionTitle' => $positionTitle,
'statusLabel' => $statusLabel,
'adminNotes' => trim((string) ($application['admin_notes'] ?? '')),
]);
try {
(new EmailService())->send($email, 'Application status update: ' . $positionTitle, $body, 'general');
} catch (\Throwable $e) {
log_message('error', 'Job application status email failed: ' . $e->getMessage());
}
}
private function currentUserId(): ?int
{
$userId = (int) session()->get('user_id');
return $userId > 0 ? $userId : null;
}
private function newUuid(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
}