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 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->openPositions(), ]); } 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 (!$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) { $payload = $this->positionPayload(); if ($payload === null) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } 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')), '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'] ?? ''), '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 = '
New application received for ' . esc($position['title']) . '.
' . 'Applicant: ' . esc($fullName) . '
Email: ' . esc($application['email']) . '
Phone: ' . esc($application['phone']) . '