add open position system
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
<?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 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 = '<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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user