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
-
-
+
+
+
+
+ = esc($position['title']) ?>
+
+ = esc($position['department'] ?? '') ?>
+ · = esc($position['location']) ?>
+ · = esc($position['employment_type']) ?>
+
+
+
+
+ - = esc(preg_replace('/^\s*-\s*/', '', $requirement)) ?>
+
+
+
+ View Details
+
+
+
+
+
+
+ No open positions right now
+ Please check back for future opportunities to serve with Al Rahma Sunday School.
+
+
+
@@ -379,4 +369,4 @@