From dbfd72c2f9d23cb656f65217017fefca80317e04 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 2 Sep 2026 00:53:55 -0400 Subject: [PATCH] add open position system --- app/Config/Routes.php | 24 +- app/Controllers/View/JobPostingController.php | 508 ++++++++++++++++++ app/Controllers/View/UserController.php | 14 +- .../2026-09-01-000100_CreateJobPostings.php | 98 ++++ ...-09-01-000110_SeedVolunteerJobPostings.php | 123 +++++ app/Models/ApplicationModel.php | 26 + app/Models/JobPositionModel.php | 35 ++ app/Models/JobTemplateModel.php | 30 ++ app/Models/JobTemplateVersionModel.php | 27 + app/Views/careers.php | 70 ++- .../emails/job_application_confirmation.php | 7 + .../emails/job_application_status_update.php | 22 + app/Views/index.php | 119 +++- app/Views/jobs/admin/_posting_fields.php | 27 + app/Views/jobs/admin/applications.php | 62 +++ app/Views/jobs/admin/position_form.php | 27 + app/Views/jobs/admin/positions.php | 38 ++ app/Views/jobs/admin/template_form.php | 47 ++ app/Views/jobs/admin/templates.php | 53 ++ app/Views/jobs/apply.php | 187 +++++++ app/Views/jobs/received.php | 14 + app/Views/jobs/show.php | 110 ++++ app/Views/partials/navbar_back.php | 4 + docs/enrollment_eligibility_plan.md | 2 +- 24 files changed, 1627 insertions(+), 47 deletions(-) create mode 100644 app/Controllers/View/JobPostingController.php create mode 100644 app/Database/Migrations/2026-09-01-000100_CreateJobPostings.php create mode 100644 app/Database/Migrations/2026-09-01-000110_SeedVolunteerJobPostings.php create mode 100644 app/Models/ApplicationModel.php create mode 100644 app/Models/JobPositionModel.php create mode 100644 app/Models/JobTemplateModel.php create mode 100644 app/Models/JobTemplateVersionModel.php create mode 100644 app/Views/emails/job_application_confirmation.php create mode 100644 app/Views/emails/job_application_status_update.php create mode 100644 app/Views/jobs/admin/_posting_fields.php create mode 100644 app/Views/jobs/admin/applications.php create mode 100644 app/Views/jobs/admin/position_form.php create mode 100644 app/Views/jobs/admin/positions.php create mode 100644 app/Views/jobs/admin/template_form.php create mode 100644 app/Views/jobs/admin/templates.php create mode 100644 app/Views/jobs/apply.php create mode 100644 app/Views/jobs/received.php create mode 100644 app/Views/jobs/show.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 1175842..4b9cfaa 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -222,7 +222,29 @@ $routes->post('/user/store', 'View\UserController::store'); $routes->get('/thankyou', 'View\UserController::thankyou'); // Thank you page route $routes->get('/', 'View\UserController::home'); // Home page route $routes->get('/about', 'View\UserController::about'); // About page route -$routes->get('/careers', 'View\UserController::careers'); // Careers page route +$routes->get('/careers', 'View\JobPostingController::publicIndex'); // Careers page route +$routes->get('/careers/application-received', 'View\JobPostingController::applicationReceived'); +$routes->get('/careers/(:segment)', 'View\JobPostingController::show/$1'); +$routes->get('/careers/(:segment)/apply', 'View\JobPostingController::apply/$1'); +$routes->post('/careers/(:segment)/apply', 'View\JobPostingController::submitApplication/$1'); +$routes->group('administrator/job-postings', ['filter' => 'auth:administrator|administrative staff|principal'], static function ($routes) { + $routes->get('', 'View\JobPostingController::positions'); + $routes->get('templates', 'View\JobPostingController::templates'); + $routes->get('templates/new', 'View\JobPostingController::newTemplate'); + $routes->post('templates', 'View\JobPostingController::createTemplate'); + $routes->get('templates/(:segment)/edit', 'View\JobPostingController::editTemplate/$1'); + $routes->post('templates/(:segment)', 'View\JobPostingController::updateTemplate/$1'); + $routes->post('templates/(:segment)/archive', 'View\JobPostingController::archiveTemplate/$1'); + $routes->post('templates/versions/(:segment)/restore', 'View\JobPostingController::restoreTemplateVersion/$1'); + $routes->get('positions', 'View\JobPostingController::positions'); + $routes->get('positions/new', 'View\JobPostingController::newPosition'); + $routes->post('positions', 'View\JobPostingController::createPosition'); + $routes->get('positions/(:segment)/edit', 'View\JobPostingController::editPosition/$1'); + $routes->post('positions/(:segment)', 'View\JobPostingController::updatePosition/$1'); + $routes->get('applications', 'View\JobPostingController::applications'); + $routes->post('applications/(:segment)', 'View\JobPostingController::updateApplication/$1'); + $routes->get('applications/(:segment)/resume', 'View\JobPostingController::resume/$1'); +}); $routes->get('/classes', 'View\UserController::classes'); // Classes page route $routes->get('/contact', 'View\UserController::contact'); // Contact Us page route $routes->post('/user/login', 'AuthController::login'); diff --git a/app/Controllers/View/JobPostingController.php b/app/Controllers/View/JobPostingController.php new file mode 100644 index 0000000..609ad16 --- /dev/null +++ b/app/Controllers/View/JobPostingController.php @@ -0,0 +1,508 @@ +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']) . '

'; + + $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)); + } +} diff --git a/app/Controllers/View/UserController.php b/app/Controllers/View/UserController.php index 24c9460..2027c4b 100644 --- a/app/Controllers/View/UserController.php +++ b/app/Controllers/View/UserController.php @@ -12,6 +12,7 @@ use App\Models\UserRoleModel; use App\Models\PasswordResetRequestModel; use App\Models\RolePermissionModel; use App\Models\IpAttemptModel; +use App\Models\JobPositionModel; use CodeIgniter\Controller; use App\Controllers\View\EmailController; use App\Models\LoginActivityModel; // Make sure this import is present @@ -108,7 +109,18 @@ class UserController extends BaseController // Method to show the home page public function home() { - return view('/index'); + $openPositions = []; + try { + if ($this->db->tableExists('job_positions')) { + $openPositions = (new JobPositionModel())->openPositions(); + } + } catch (\Throwable $e) { + log_message('error', 'Unable to load home page job positions: ' . $e->getMessage()); + } + + return view('/index', [ + 'openPositions' => $openPositions, + ]); } // Method to show the about page diff --git a/app/Database/Migrations/2026-09-01-000100_CreateJobPostings.php b/app/Database/Migrations/2026-09-01-000100_CreateJobPostings.php new file mode 100644 index 0000000..ed5f9e9 --- /dev/null +++ b/app/Database/Migrations/2026-09-01-000100_CreateJobPostings.php @@ -0,0 +1,98 @@ +db->tableExists('job_templates')) { + $this->forge->addField([ + 'template_id' => ['type' => 'VARCHAR', 'constraint' => 36], + 'title' => ['type' => 'VARCHAR', 'constraint' => 255], + 'description' => ['type' => 'TEXT', 'null' => true], + 'department' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], + 'location' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], + 'employment_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'requirements' => ['type' => 'TEXT', 'null' => true], + 'version' => ['type' => 'INT', 'constraint' => 11, 'default' => 1], + 'is_active' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1], + 'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('template_id', true); + $this->forge->addKey('is_active', false, false, 'idx_job_templates_active'); + $this->forge->createTable('job_templates', true); + } + + if (!$this->db->tableExists('job_template_versions')) { + $this->forge->addField([ + 'version_id' => ['type' => 'VARCHAR', 'constraint' => 36], + 'template_id' => ['type' => 'VARCHAR', 'constraint' => 36], + 'version' => ['type' => 'INT', 'constraint' => 11], + 'title' => ['type' => 'VARCHAR', 'constraint' => 255], + 'description' => ['type' => 'TEXT', 'null' => true], + 'department' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], + 'location' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], + 'employment_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'requirements' => ['type' => 'TEXT', 'null' => true], + 'saved_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'saved_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('version_id', true); + $this->forge->addKey('template_id', false, false, 'idx_template_versions_template_id'); + $this->forge->createTable('job_template_versions', true); + } + + if (!$this->db->tableExists('job_positions')) { + $this->forge->addField([ + 'position_id' => ['type' => 'VARCHAR', 'constraint' => 36], + 'template_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => true], + 'title' => ['type' => 'VARCHAR', 'constraint' => 255], + 'description' => ['type' => 'TEXT', 'null' => true], + 'department' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], + 'location' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], + 'employment_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'requirements' => ['type' => 'TEXT', 'null' => true], + 'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'draft'], + 'posted_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('position_id', true); + $this->forge->addKey('status', false, false, 'idx_job_positions_status'); + $this->forge->createTable('job_positions', true); + } + + if (!$this->db->tableExists('applications')) { + $this->forge->addField([ + 'application_id' => ['type' => 'VARCHAR', 'constraint' => 36], + 'position_id' => ['type' => 'VARCHAR', 'constraint' => 36], + 'first_name' => ['type' => 'VARCHAR', 'constraint' => 100], + 'last_name' => ['type' => 'VARCHAR', 'constraint' => 100], + 'email' => ['type' => 'VARCHAR', 'constraint' => 255], + 'phone' => ['type' => 'VARCHAR', 'constraint' => 30], + 'resume_file_url' => ['type' => 'TEXT'], + 'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'new'], + 'admin_notes' => ['type' => 'TEXT', 'null' => true], + 'submitted_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('application_id', true); + $this->forge->addKey('position_id', false, false, 'idx_applications_position_id'); + $this->forge->addKey('status', false, false, 'idx_applications_status'); + $this->forge->addKey('email', false, false, 'idx_applications_email'); + $this->forge->createTable('applications', true); + } + } + + public function down() + { + $this->forge->dropTable('applications', true); + $this->forge->dropTable('job_positions', true); + $this->forge->dropTable('job_template_versions', true); + $this->forge->dropTable('job_templates', true); + } +} diff --git a/app/Database/Migrations/2026-09-01-000110_SeedVolunteerJobPostings.php b/app/Database/Migrations/2026-09-01-000110_SeedVolunteerJobPostings.php new file mode 100644 index 0000000..600ee6d --- /dev/null +++ b/app/Database/Migrations/2026-09-01-000110_SeedVolunteerJobPostings.php @@ -0,0 +1,123 @@ + 'f9df54cc-0173-4c5d-a3b6-d7ef64821e01', + 'position_id' => 'cc23227b-82b1-4bcb-8976-fcd21f0a1e01', + 'version_id' => '6a408f24-88db-4c0e-b7f2-26cc2da51e01', + 'title' => 'Grade 6 Teacher Volunteer', + 'description' => "Al Rahma Sunday School is looking for a dedicated Grade 6 Teacher Volunteer to teach and mentor students in a positive Islamic learning environment. The teacher will help students strengthen their understanding of Islamic studies, Quran, Arabic, manners, and character development.\n\nResponsibilities:\n- Teach Grade 6 Sunday school lessons based on the school curriculum\n- Prepare weekly lesson plans and classroom activities\n- Help students understand Islamic values and apply them in daily life\n- Manage classroom behavior in a respectful and positive way\n- Encourage student participation, discussion, and teamwork\n- Track attendance and student progress\n- Communicate with school administration and parents when needed\n- Support school events, exams, and activities when requested", + 'requirements' => "- Strong commitment to Islamic education and community service\n- Comfortable teaching and guiding middle school students\n- Patient, responsible, organized, and dependable\n- Good communication skills\n- Prior teaching, tutoring, or youth mentoring experience preferred\n- Must be available during Sunday school hours", + ], + [ + 'template_id' => 'f9df54cc-0173-4c5d-a3b6-d7ef64821e02', + 'position_id' => 'cc23227b-82b1-4bcb-8976-fcd21f0a1e02', + 'version_id' => '6a408f24-88db-4c0e-b7f2-26cc2da51e02', + 'title' => 'Grade 8 Teacher Volunteer', + 'description' => "Al Rahma Sunday School is seeking a Grade 8 Teacher Volunteer to support students as they continue developing their Islamic knowledge, personal responsibility, and connection to the Muslim community.\n\nThe Grade 8 teacher will lead classroom lessons, encourage meaningful discussion, and help students build confidence in their faith and character.\n\nResponsibilities:\n- Teach Grade 8 Sunday school curriculum\n- Prepare engaging lessons, discussions, and activities\n- Help students understand Islamic teachings in age-appropriate ways\n- Encourage respectful dialogue, critical thinking, and positive behavior\n- Support students with Quran, Islamic studies, Arabic, and character development\n- Maintain classroom order and a safe learning environment\n- Monitor attendance and student participation\n- Communicate with administration and parents as needed\n- Assist with school programs, projects, or events when requested", + 'requirements' => "- Commitment to Islamic values and youth education\n- Ability to connect with middle school students\n- Responsible, patient, respectful, and organized\n- Strong communication and classroom management skills\n- Teaching, tutoring, halaqa, or mentoring experience preferred\n- Must be dependable and available on Sundays", + ], + [ + 'template_id' => 'f9df54cc-0173-4c5d-a3b6-d7ef64821e03', + 'position_id' => 'cc23227b-82b1-4bcb-8976-fcd21f0a1e03', + 'version_id' => '6a408f24-88db-4c0e-b7f2-26cc2da51e03', + 'title' => 'Youth Teacher / Mentor Volunteer', + 'description' => "Al Rahma Sunday School is looking for a Youth Teacher / Mentor Volunteer to work with students ages 16-17. This role focuses on teaching, mentoring, and guiding older teens as they strengthen their Islamic identity, personal responsibility, leadership skills, and connection to the Muslim community.\n\nThe Youth Teacher will lead age-appropriate lessons and discussions that connect Islamic values to real-life topics students face at this stage of life.\n\nResponsibilities:\n- Teach and mentor students ages 16-17\n- Lead discussions on Islamic identity, character, Quran, Seerah, manners, leadership, and real-life challenges\n- Create a respectful classroom environment where students feel comfortable asking questions\n- Encourage critical thinking, responsibility, positive decision-making, and community involvement\n- Prepare weekly lessons, activities, or discussion topics\n- Support students in developing confidence in their faith and values\n- Maintain appropriate teacher-student boundaries and a safe learning environment\n- Track attendance and student participation\n- Communicate with Sunday school administration and parents when needed\n- Support youth projects, events, service activities, or school programs when requested", + 'requirements' => "- Strong commitment to Islamic education and youth development\n- Ability to relate respectfully and effectively with older teens\n- Comfortable leading meaningful discussions with students ages 16-17\n- Patient, dependable, trustworthy, and organized\n- Good communication, leadership, and classroom management skills\n- Prior experience in teaching, mentoring, halaqas, youth programs, or community service preferred\n- Must be available during Sunday school hours", + ], + ]; + + public function up() + { + if (!$this->db->tableExists('job_templates') || !$this->db->tableExists('job_positions')) { + return; + } + + $now = gmdate('Y-m-d H:i:s'); + + foreach ($this->postings as $posting) { + $shared = [ + 'title' => $posting['title'], + 'description' => $posting['description'], + 'department' => 'Sunday School', + 'location' => 'Al Rahma Sunday School', + 'employment_type' => 'Volunteer', + 'requirements' => $posting['requirements'], + ]; + + $template = $this->db->table('job_templates') + ->where('template_id', $posting['template_id']) + ->orWhere('title', $posting['title']) + ->get() + ->getRowArray(); + + $templateId = (string) ($template['template_id'] ?? $posting['template_id']); + + if (!$template) { + $this->db->table('job_templates')->insert($shared + [ + 'template_id' => $templateId, + 'version' => 1, + 'is_active' => 1, + 'created_by' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + if ($this->db->tableExists('job_template_versions')) { + $versionExists = $this->db->table('job_template_versions') + ->where('version_id', $posting['version_id']) + ->countAllResults() > 0; + + if (!$versionExists) { + $this->db->table('job_template_versions')->insert($shared + [ + 'version_id' => $posting['version_id'], + 'template_id' => $templateId, + 'version' => 1, + 'saved_by' => null, + 'saved_at' => $now, + ]); + } + } + + $positionExists = $this->db->table('job_positions') + ->where('position_id', $posting['position_id']) + ->orWhere('title', $posting['title']) + ->countAllResults() > 0; + + if (!$positionExists) { + $this->db->table('job_positions')->insert($shared + [ + 'position_id' => $posting['position_id'], + 'template_id' => $templateId, + 'status' => 'draft', + 'posted_by' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + public function down() + { + foreach ($this->postings as $posting) { + if ($this->db->tableExists('job_positions')) { + $this->db->table('job_positions')->where('position_id', $posting['position_id'])->delete(); + } + + if ($this->db->tableExists('job_template_versions')) { + $this->db->table('job_template_versions')->where('version_id', $posting['version_id'])->delete(); + } + + if ($this->db->tableExists('job_templates')) { + $this->db->table('job_templates')->where('template_id', $posting['template_id'])->delete(); + } + } + } +} diff --git a/app/Models/ApplicationModel.php b/app/Models/ApplicationModel.php new file mode 100644 index 0000000..e7dbf79 --- /dev/null +++ b/app/Models/ApplicationModel.php @@ -0,0 +1,26 @@ +where('status', 'open')->orderBy('created_at', 'DESC')->findAll(); + } +} diff --git a/app/Models/JobTemplateModel.php b/app/Models/JobTemplateModel.php new file mode 100644 index 0000000..0f86957 --- /dev/null +++ b/app/Models/JobTemplateModel.php @@ -0,0 +1,30 @@ +Review the available roles below and apply by creating an account.

-
-
-

Volunteer Teacher

-
Instruction · Chelmsford, MA · Part-time volunteer
-

Lead Quran, Arabic or Islamic Studies lessons in a warm classroom environment.

-
    -
  • Prepare weekly lessons and classroom activities.
  • -
  • Guide students with patience and clear communication.
  • -
  • Partner with school administration and families when needed.
  • -
- Apply Now -
-
-
-
-

Teacher Assistant

-
Classroom Support · Chelmsford, MA · Part-time volunteer
-

Support teachers and help students stay engaged throughout Sunday classes.

-
    -
  • Assist with activities, materials and classroom routines.
  • -
  • Provide individual support to students during lessons.
  • -
  • Help maintain a respectful and focused learning environment.
  • -
- Apply Now -
-
-
-
-

Administrative Volunteer

-
Operations · Chelmsford, MA · Part-time volunteer
-

Help the school operate smoothly through weekly administrative and event support.

-
    -
  • Support communications, supplies and weekly coordination.
  • -
  • Help organize school events and program logistics.
  • -
  • Assist staff with structured tasks before or during Sunday school.
  • -
- Apply Now -
-
+ + +
+ +
+ + +
+
+

No open positions right now

+

Please check back for future opportunities to serve with Al Rahma Sunday School.

+
+
+
@@ -379,4 +369,4 @@ - \ No newline at end of file + diff --git a/app/Views/emails/job_application_confirmation.php b/app/Views/emails/job_application_confirmation.php new file mode 100644 index 0000000..886da13 --- /dev/null +++ b/app/Views/emails/job_application_confirmation.php @@ -0,0 +1,7 @@ +

Assalamu alaikum ,

+ +

Thank you for applying for the position at Al Rahma Sunday School.

+ +

We received your application and will review it with the hiring team. If your background matches the role, we will follow up with next steps.

+ +

Al Rahma Sunday School

diff --git a/app/Views/emails/job_application_status_update.php b/app/Views/emails/job_application_status_update.php new file mode 100644 index 0000000..7e87d5e --- /dev/null +++ b/app/Views/emails/job_application_status_update.php @@ -0,0 +1,22 @@ +

Assalamu alaikum ,

+ + 'Thank you for applying for ' . esc($positionTitle) . '. We have received your application and it is now in our review queue.', + 'Reviewed' => 'Thank you for applying for ' . esc($positionTitle) . '. Our team has reviewed your application and will contact you if additional information or next steps are needed.', + 'Contacted' => 'Thank you for applying for ' . esc($positionTitle) . '. Our team has moved your application forward and has contacted you, or will contact you shortly, about next steps.', + 'Rejected' => 'Thank you for your interest in serving as ' . esc($positionTitle) . '. After reviewing the current needs of the program, we are not moving forward with your application for this role at this time. We sincerely appreciate your willingness to support Al Rahma Sunday School and encourage you to consider future volunteer opportunities.', + 'Hired' => 'Congratulations. We are pleased to move forward with your application for ' . esc($positionTitle) . '. Our team will follow up with next steps.', +]; +$message = $statusMessages[$statusLabel] ?? 'Thank you for applying for ' . esc($positionTitle) . '. We are writing to share an update about your application.'; +?> + +

+ + +

Message from Al Rahma Sunday School:

+ + +

Thank you for your interest in serving with Al Rahma Sunday School.

+ +

Al Rahma Sunday School

diff --git a/app/Views/index.php b/app/Views/index.php index 79249ea..5fdc47e 100644 --- a/app/Views/index.php +++ b/app/Views/index.php @@ -198,6 +198,74 @@ background-color: var(--accent); } + /* Open positions banner */ + .careers-ticker { + display: block; + background-color: var(--primary-dark); + color: var(--paper); + text-decoration: none; + overflow: hidden; + border-top: 1px solid rgba(243, 239, 227, 0.14); + border-bottom: 1px solid rgba(243, 239, 227, 0.14); + } + + .careers-ticker:hover, + .careers-ticker:focus { + color: var(--paper); + } + + .ticker-track { + display: flex; + width: max-content; + animation: ticker-scroll 10s linear infinite; + } + + .careers-ticker:hover .ticker-track, + .careers-ticker:focus .ticker-track, + .careers-ticker:focus-within .ticker-track { + animation-play-state: paused; + } + + .ticker-group { + display: flex; + align-items: center; + min-width: max-content; + } + + .ticker-label, + .ticker-item { + display: inline-flex; + align-items: center; + min-height: 54px; + white-space: nowrap; + } + + .ticker-label { + padding: 0 1.5rem; + font-weight: 700; + color: var(--accent-soft); + } + + .ticker-item { + gap: 0.5rem; + padding: 0 1.75rem; + border-left: 1px solid rgba(243, 239, 227, 0.16); + } + + .ticker-title { + font-weight: 700; + } + + .ticker-meta { + color: #DCE6DD; + font-size: 0.9rem; + } + + @keyframes ticker-scroll { + from { transform: translateX(100vw); } + to { transform: translateX(-50%); } + } + @media (max-width: 992px) { .carousel-item { height: 480px; } .carousel-caption { max-width: 78%; padding: 1.5rem; bottom: 8%; } @@ -209,6 +277,9 @@ .carousel-caption { left: 5%; right: 5%; max-width: none; padding: 1.1rem 1.25rem; bottom: 6%; } .carousel-caption h1 { font-size: 1.2rem; } .carousel-caption p { font-size: 0.85rem; } + .ticker-track { animation-duration: 10s; } + .ticker-label, .ticker-item { min-height: 48px; } + .ticker-item { padding: 0 1.25rem; } } /* Join line */ @@ -506,6 +577,46 @@ + trim((string) $value) !== ''); + $tickerItems[] = [ + 'title' => (string) ($position['title'] ?? 'Open Position'), + 'meta' => implode(' · ', $meta), + ]; + } + if ($tickerItems === []) { + $tickerItems[] = [ + 'title' => 'Volunteer opportunities', + 'meta' => 'See current openings', + ]; + } + ?> + +
+ +
> + Now recruiting volunteers + + + + + + + + + +
+ +
+
+
@@ -515,7 +626,7 @@
-
+

Active Participants

@@ -644,7 +755,7 @@ - \ No newline at end of file + diff --git a/app/Views/jobs/admin/_posting_fields.php b/app/Views/jobs/admin/_posting_fields.php new file mode 100644 index 0000000..3bbd574 --- /dev/null +++ b/app/Views/jobs/admin/_posting_fields.php @@ -0,0 +1,27 @@ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/app/Views/jobs/admin/applications.php b/app/Views/jobs/admin/applications.php new file mode 100644 index 0000000..4750c4c --- /dev/null +++ b/app/Views/jobs/admin/applications.php @@ -0,0 +1,62 @@ +extend('layout/management_layout') ?> +section('content') ?> + +
+ +
+
+
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + +
ApplicantPositionPosition IDEmailPhoneSubmittedStatus / NotesResume
+
+ + + + +
+
Download
+
+
+ +endSection() ?> diff --git a/app/Views/jobs/admin/position_form.php b/app/Views/jobs/admin/position_form.php new file mode 100644 index 0000000..799c23b --- /dev/null +++ b/app/Views/jobs/admin/position_form.php @@ -0,0 +1,27 @@ +extend('layout/management_layout') ?> +section('content') ?> + + +
+

+ +
+ +
+ + + $position ?? []]) ?> +
+ + +
+ + Cancel +
+
+ +endSection() ?> diff --git a/app/Views/jobs/admin/positions.php b/app/Views/jobs/admin/positions.php new file mode 100644 index 0000000..33cd99b --- /dev/null +++ b/app/Views/jobs/admin/positions.php @@ -0,0 +1,38 @@ +extend('layout/management_layout') ?> +section('content') ?> + +
+
+

Open Positions

+ +
+
+
+
+ + + + + + + + + + + + + + +
TitleDepartmentLocationTypeStatusActions
+ Edit + + Public View + +
+
+
+ +endSection() ?> diff --git a/app/Views/jobs/admin/template_form.php b/app/Views/jobs/admin/template_form.php new file mode 100644 index 0000000..6381ef6 --- /dev/null +++ b/app/Views/jobs/admin/template_form.php @@ -0,0 +1,47 @@ +extend('layout/management_layout') ?> +section('content') ?> + + +
+

+ +
+ +
+ + $template ?? []]) ?> + +
+ + +
+ + + Cancel +
+ +

Version History

+ + + + + + + + + + + +
VersionSaved AtActions
+
+ + +
+
+ +
+ +endSection() ?> diff --git a/app/Views/jobs/admin/templates.php b/app/Views/jobs/admin/templates.php new file mode 100644 index 0000000..f80a9ce --- /dev/null +++ b/app/Views/jobs/admin/templates.php @@ -0,0 +1,53 @@ +extend('layout/management_layout') ?> +section('content') ?> + +
+
+

Job Templates

+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TitleDepartmentLocationTypeVersionStatusActions
+ Edit + Create Position + +
+ + +
+ +
+
+
+ +endSection() ?> diff --git a/app/Views/jobs/apply.php b/app/Views/jobs/apply.php new file mode 100644 index 0000000..df9276d --- /dev/null +++ b/app/Views/jobs/apply.php @@ -0,0 +1,187 @@ +extend('layout/main_layout') ?> +section('styles') ?> + +endSection() ?> + +section('content') ?> + +
+ Back to position +
+
+

Apply:

+
+ +
+ +
+ +
+ +

(All fields with * are required)

+
+ +
+
+ + +
2-30 characters. Letters, spaces, and dashes only.
+
+
+
+ + +
2-30 characters. Letters, spaces, and dashes only.
+
+
+
+ + +
Valid email format, maximum 50 characters.
+
+
+
+ + +
Enter a 10-digit phone number, for example 123-456-7890.
+
+
+
+ + +
PDF, DOC, or DOCX. Maximum size 5 MB.
+
+
+ +
+
+
+
+ + + +endSection() ?> diff --git a/app/Views/jobs/received.php b/app/Views/jobs/received.php new file mode 100644 index 0000000..4cdeb78 --- /dev/null +++ b/app/Views/jobs/received.php @@ -0,0 +1,14 @@ +extend('layout/main_layout') ?> +section('content') ?> + +
+
+
+
Thank you. Your application has been received.
+

We will review your submission and follow up if there is a match for the role.

+ Return to Openings +
+
+
+ +endSection() ?> diff --git a/app/Views/jobs/show.php b/app/Views/jobs/show.php new file mode 100644 index 0000000..5e5010d --- /dev/null +++ b/app/Views/jobs/show.php @@ -0,0 +1,110 @@ +extend('layout/main_layout') ?> +section('styles') ?> + +endSection() ?> + +section('content') ?> + +' . esc(implode(' ', $paragraph)) . '

'; + $paragraph = []; + }; + + $flushList = static function () use (&$html, &$list): void { + if ($list === []) { + return; + } + + $html .= '
    '; + foreach ($list as $item) { + $html .= '
  • ' . esc($item) . '
  • '; + } + $html .= '
'; + $list = []; + }; + + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '') { + $flushParagraph(); + $flushList(); + continue; + } + + if (str_ends_with($line, ':')) { + $flushParagraph(); + $flushList(); + $html .= '

' . esc(rtrim($line, ':')) . '

'; + continue; + } + + if (str_starts_with($line, '- ')) { + $flushParagraph(); + $list[] = substr($line, 2); + continue; + } + + $flushList(); + $paragraph[] = $line; + } + + $flushParagraph(); + $flushList(); + + return $html; +}; +?> + +
+ Back to openings +
+
+

+

+ + · + · +

+

Description

+
+

Requirements

+
+
+
+
+
+

Apply for this position

+

Submit your contact information and resume for review.

+ Apply Now +
+
+
+
+
+ +endSection() ?> diff --git a/app/Views/partials/navbar_back.php b/app/Views/partials/navbar_back.php index 2c91987..72ce2cf 100644 --- a/app/Views/partials/navbar_back.php +++ b/app/Views/partials/navbar_back.php @@ -58,6 +58,7 @@ $role = strtolower(session()->get('role') ?? 'guest'); @@ -190,6 +191,9 @@ $role = strtolower(session()->get('role') ?? 'guest'); diff --git a/docs/enrollment_eligibility_plan.md b/docs/enrollment_eligibility_plan.md index f95c37d..2d8aaa5 100644 --- a/docs/enrollment_eligibility_plan.md +++ b/docs/enrollment_eligibility_plan.md @@ -2,7 +2,7 @@ ## 1. Goal -Tighten re-enrollment eligibility for existing Al-Rahma Sunday School students without changing the separate new-student registration flow. Parents should only be able to submit re-enrollment for students already linked to their account and already transitioned or eligible for transition into the selected school year. +Tighten re-enrollment eligibility for existing Al Rahma Sunday School students without changing the separate new-student registration flow. Parents should only be able to submit re-enrollment for students already linked to their account and already transitioned or eligible for transition into the selected school year. This plan matches the current project enrollment architecture: