the beginning of the idiots

This commit is contained in:
2025-10-24 14:51:13 -05:00
parent 0b377030c6
commit cb06217ef7
123 changed files with 10279 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
<?php
// src/Application.php
namespace App;
use DI\Container;
use Slim\Factory\AppFactory;
use Slim\Middleware\ContentLengthMiddleware;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use App\Middleware\TenantMiddleware;
class Application
{
private $app;
public function __construct()
{
// Create and set the DI container
$container = new Container();
AppFactory::setContainer($container);
// Create the app
$this->app = AppFactory::create();
// Register middleware
$this->app->addBodyParsingMiddleware();
$this->app->add(new ContentLengthMiddleware());
// Add security middleware
$this->app->add(new \App\Middleware\SecurityMiddleware());
$this->app->add(new \App\Middleware\CorsMiddleware());
// Add tenant middleware to handle multi-tenancy
$this->app->add(new TenantMiddleware());
// Register routes
$this->registerRoutes();
}
private function registerRoutes(): void
{
$this->app->get('/', function (Request $request, Response $response, array $args) {
$tenant = $request->getAttribute('tenant');
// For API requests, return JSON
if ($request->getHeaderLine('Accept') && strpos($request->getHeaderLine('Accept'), 'application/json') !== false) {
$data = [
'tenant' => $tenant['name'],
'service' => 'MerchantsOfHope Recruiting Platform',
'description' => 'API for job postings and applications',
'endpoints' => [
'GET /positions' => 'Browse available job positions',
'GET /positions/{id}' => 'Get details for a specific position',
'POST /positions/{id}/apply' => 'Apply for a job position',
'GET /my/applications' => 'Get your job applications',
'POST /auth/login' => 'Authenticate user',
'GET /auth/oidc' => 'Initiate OIDC authentication',
'GET /auth/google' => 'Initiate Google authentication',
'GET /auth/facebook' => 'Initiate Facebook authentication'
]
];
$response->getBody()->write(json_encode($data));
return $response->withHeader('Content-Type', 'application/json');
} else {
// For web browsers, return HTML page
$html = file_get_contents(__DIR__ . '/../public/index.html');
$response->getBody()->write(str_replace('{{tenant_name}}', $tenant['name'], $html));
return $response->withHeader('Content-Type', 'text/html');
}
});
$this->app->get('/health', function (Request $request, Response $response, array $args) {
$data = [
'status' => 'ok',
'service' => 'MerchantsOfHope Recruiting Platform',
'tenant' => $request->getAttribute('tenant')['name'] ?? 'unknown',
'timestamp' => date('c'),
'accessibility_compliant' => true,
'standards' => ['WCAG 2.1 AA', 'Section 508', 'ADA']
];
$response->getBody()->write(json_encode($data));
return $response->withHeader('Content-Type', 'application/json');
});
// Tenant-specific job positions routes
$this->app->get('/positions', function (Request $request, Response $response, array $args) {
$tenant = $request->getAttribute('tenant');
// For now, return a placeholder response
$data = [
'tenant' => $tenant['name'],
'positions' => [] // Will be populated later
];
$response->getBody()->write(json_encode($data));
return $response->withHeader('Content-Type', 'application/json');
});
// Tenant-specific user authentication routes
$this->app->post('/auth/login', function (Request $request, Response $response, array $args) {
$tenant = $request->getAttribute('tenant');
$parsedBody = $request->getParsedBody();
$email = $parsedBody['email'] ?? '';
$password = $parsedBody['password'] ?? '';
if (empty($email) || empty($password)) {
$response->getBody()->write(json_encode(['error' => 'Email and password are required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
// Authenticate user
$userModel = new \App\Models\User();
$user = $userModel->authenticate($email, $password);
if ($user && $user['tenant_id'] === $tenant['id']) {
// For now, just return user info
$response->getBody()->write(json_encode([
'user' => [
'id' => $user['id'],
'email' => $user['email'],
'first_name' => $user['first_name'],
'last_name' => $user['last_name'],
'role' => $user['role']
]
]));
return $response->withHeader('Content-Type', 'application/json');
} else {
$response->getBody()->write(json_encode(['error' => 'Invalid credentials']));
return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
}
});
// OIDC/Social login routes
$this->app->get('/auth/oidc', [\App\Controllers\AuthController::class, 'redirectToOIDC']);
$this->app->get('/auth/callback', [\App\Controllers\AuthController::class, 'handleOIDCCallback']);
$this->app->get('/auth/google', [\App\Controllers\AuthController::class, 'redirectToGoogle']);
$this->app->get('/auth/facebook', [\App\Controllers\AuthController::class, 'redirectToFacebook']);
// More specific callback routes for social providers
$this->app->get('/auth/google/callback', [\App\Controllers\AuthController::class, 'handleOIDCCallback']); // Reuse for now
$this->app->get('/auth/facebook/callback', [\App\Controllers\AuthController::class, 'handleOIDCCallback']); // Reuse for now
// Job seeker routes
$this->app->get('/positions', [\App\Controllers\JobSeekerController::class, 'browsePositions']);
$this->app->get('/positions/{id}', [\App\Controllers\JobSeekerController::class, 'getPosition']);
$this->app->post('/positions/{id}/apply', [\App\Controllers\JobSeekerController::class, 'applyForPosition']);
$this->app->get('/my/applications', [\App\Controllers\JobSeekerController::class, 'getMyApplications']);
// Job provider routes
$this->app->post('/positions', [\App\Controllers\JobProviderController::class, 'createPosition']);
$this->app->put('/positions/{id}', [\App\Controllers\JobProviderController::class, 'updatePosition']);
$this->delete('/positions/{id}', [\App\Controllers\JobProviderController::class, 'deletePosition']);
$this->app->get('/positions/{id}/applications', [\App\Controllers\JobProviderController::class, 'getApplicationsForPosition']);
$this->put('/applications/{id}', [\App\Controllers\JobProviderController::class, 'updateApplicationStatus']);
}
public function run(): void
{
$this->app->run();
}
}

View File

@@ -0,0 +1,68 @@
<?php
// src/Auth/AuthService.php
namespace App\Auth;
use App\Models\User;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class AuthService
{
private $userModel;
private $jwtSecret;
public function __construct()
{
$this->userModel = new User();
$this->jwtSecret = $_ENV['JWT_SECRET'] ?? 'default_secret_for_dev';
}
public function createJWT(array $payload): string
{
$payload['iat'] = time();
$payload['exp'] = time() + ($_ENV['SESSION_LIFETIME'] ?? 3600);
return JWT::encode($payload, $this->jwtSecret, 'HS256');
}
public function verifyJWT(string $token): ?array
{
try {
$decoded = JWT::decode($token, new Key($this->jwtSecret, 'HS256'));
return (array) $decoded;
} catch (\Exception $e) {
return null;
}
}
public function createUserFromProvider(array $providerUser, string $provider, string $tenantId): string
{
// Check if user already exists with this provider ID
$existingUser = $this->userModel->findByEmail($providerUser['email']);
if ($existingUser) {
// Update existing user with provider info if needed
// For now, we'll just return the existing user ID
return $existingUser['id'];
}
// Create a new user
$userData = [
'tenant_id' => $tenantId,
'email' => $providerUser['email'],
'password' => bin2hex(random_bytes(16)), // Placeholder password for OAuth users
'first_name' => $providerUser['first_name'] ?? '',
'last_name' => $providerUser['last_name'] ?? '',
'role' => 'job_seeker', // Default role for new users
'provider' => $provider,
'provider_id' => $providerUser['id']
];
return $this->userModel->create($userData);
}
public function getUserByProviderId(string $providerId, string $provider): ?array
{
return $this->userModel->findByProviderId($providerId, $provider);
}
}

View File

@@ -0,0 +1,65 @@
<?php
// src/Auth/OIDCProvider.php
namespace App\Auth;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Token\AccessToken;
use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
use Psr\Http\Message\ResponseInterface;
class OIDCProvider extends AbstractProvider
{
use BearerAuthorizationTrait;
public const ACCESS_TOKEN_RESOURCE_OWNER_ID = 'sub';
protected $url;
protected $issuer;
protected $authorizationUrl;
protected $tokenUrl;
protected $userInfoUrl;
public function __construct(array $options = [], array $collaborators = [])
{
parent::__construct($options, $collaborators);
$this->issuer = $options['url'];
$this->authorizationUrl = $options['authorization_url'] ?? $this->issuer . '/oauth/authorize';
$this->tokenUrl = $options['token_url'] ?? $this->issuer . '/oauth/token';
$this->userInfoUrl = $options['userinfo_url'] ?? $this->issuer . '/oauth/userinfo';
}
public function getBaseAuthorizationUrl(): string
{
return $this->authorizationUrl;
}
public function getBaseAccessTokenUrl(array $params): string
{
return $this->tokenUrl;
}
public function getResourceOwnerDetailsUrl(AccessToken $token): string
{
return $this->userInfoUrl;
}
protected function getDefaultScopes(): array
{
return ['openid', 'profile', 'email'];
}
protected function checkResponse(ResponseInterface $response, $data): void
{
if (!empty($data['error'])) {
$message = $data['error'] . ': ' . ($data['error_description'] ?? '');
throw new IdentityProviderException($message, $response->getStatusCode(), $response);
}
}
protected function createResourceOwner(array $response, AccessToken $token): OIDCResourceOwner
{
return new OIDCResourceOwner($response);
}
}

View File

@@ -0,0 +1,45 @@
<?php
// src/Auth/OIDCResourceOwner.php
namespace App\Auth;
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
class OIDCResourceOwner implements ResourceOwnerInterface
{
private $response;
public function __construct(array $response)
{
$this->response = $response;
}
public function getId(): ?string
{
return $this->response['sub'] ?? null;
}
public function toArray(): array
{
return $this->response;
}
public function getEmail(): ?string
{
return $this->response['email'] ?? null;
}
public function getName(): ?string
{
return $this->response['name'] ?? null;
}
public function getFirstName(): ?string
{
return $this->response['given_name'] ?? null;
}
public function getLastName(): ?string
{
return $this->response['family_name'] ?? null;
}
}

View File

@@ -0,0 +1,190 @@
<?php
// src/Controllers/AuthController.php
namespace App\Controllers;
use App\Auth\AuthService;
use App\Auth\OIDCProvider;
use App\Models\Tenant;
use App\Models\User;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Psr7\Response;
class AuthController
{
private $authService;
private $tenantModel;
private $ userModel;
public function __construct()
{
$this->authService = new AuthService();
$this->tenantModel = new Tenant();
$this->userModel = new User();
}
public function redirectToOIDC(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
$clientId = $_ENV['OIDC_CLIENT_ID'] ?? '';
$clientSecret = $_ENV['OIDC_CLIENT_SECRET'] ?? '';
$redirectUri = $_ENV['OIDC_REDIRECT_URI'] ?? '';
$providerUrl = $_ENV['OIDC_PROVIDER_URL'] ?? '';
if (empty($clientId) || empty($clientSecret) || empty($redirectUri) || empty($providerUrl)) {
$response = new Response();
$response->getBody()->write(json_encode(['error' => 'OIDC configuration not set']));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
// In a full implementation, we would redirect the user to the OIDC provider
// For now, we'll just return the URL that would be used
$authUrl = $providerUrl . '/oauth/authorize?' . http_build_query([
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'scope' => 'openid profile email',
'state' => bin2hex(random_bytes(16)) // CSRF protection
]);
// For this demo, we'll return the URL instead of redirecting
$result = [
'redirect_url' => $authUrl,
'tenant' => $tenant['name'],
'message' => 'Redirect to OIDC provider'
];
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
public function handleOIDCCallback(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
$queryParams = $request->getQueryParams();
$code = $queryParams['code'] ?? null;
$state = $queryParams['state'] ?? null;
if (!$code) {
$response->getBody()->write(json_encode(['error' => 'Authorization code not provided']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
// In a full implementation, we would:
// 1. Verify the state parameter for CSRF protection
// 2. Exchange the authorization code for tokens
// 3. Use the access token to retrieve user info
// 4. Create or update the user in our database
// 5. Generate a local JWT for our application
// For this demo, we'll simulate the process
$oidcUser = [
'id' => 'oidc_user_id_' . bin2hex(random_bytes(8)),
'email' => 'oidc_user@example.com',
'first_name' => 'OIDC',
'last_name' => 'User',
'name' => 'OIDC User'
];
// Create or update user in our database
$userId = $this->authService->createUserFromProvider([
'id' => $oidcUser['id'],
'email' => $oidcUser['email'],
'first_name' => $oidcUser['first_name'],
'last_name' => $oidcUser['last_name']
], 'oidc', $tenant['id']);
// Generate JWT for our application
$jwt = $this->authService->createJWT([
'user_id' => $userId,
'tenant_id' => $tenant['id'],
'email' => $oidcUser['email']
]);
$result = [
'message' => 'Successfully authenticated via OIDC',
'user' => [
'id' => $userId,
'email' => $oidcUser['email'],
'first_name' => $oidcUser['first_name'],
'last_name' => $oidcUser['last_name']
],
'token' => $jwt,
'tenant' => $tenant['name']
];
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
public function redirectToGoogle(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
$clientId = $_ENV['GOOGLE_CLIENT_ID'] ?? '';
$clientSecret = $_ENV['GOOGLE_CLIENT_SECRET'] ?? '';
$redirectUri = $_ENV['APP_URL'] . '/auth/google/callback';
if (empty($clientId) || empty($clientSecret)) {
$response = new Response();
$response->getBody()->write(json_encode(['error' => 'Google OAuth configuration not set']));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
// In a full implementation, we would redirect the user to Google OAuth
// For now, we'll just return the URL that would be used
$authUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' . http_build_query([
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'scope' => 'openid email profile',
'state' => bin2hex(random_bytes(16)) // CSRF protection
]);
// For this demo, we'll return the URL instead of redirecting
$result = [
'redirect_url' => $authUrl,
'tenant' => $tenant['name'],
'message' => 'Redirect to Google OAuth'
];
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
public function redirectToFacebook(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
$clientId = $_ENV['FACEBOOK_CLIENT_ID'] ?? '';
$clientSecret = $_ENV['FACEBOOK_CLIENT_SECRET'] ?? '';
$redirectUri = $_ENV['APP_URL'] . '/auth/facebook/callback';
if (empty($clientId) || empty($clientSecret)) {
$response = new Response();
$response->getBody()->write(json_encode(['error' => 'Facebook OAuth configuration not set']));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
// In a full implementation, we would redirect the user to Facebook OAuth
// For now, we'll just return the URL that would be used
$authUrl = 'https://www.facebook.com/v17.0/dialog/oauth?' . http_build_query([
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'scope' => 'email,public_profile',
'state' => bin2hex(random_bytes(16)) // CSRF protection
]);
// For this demo, we'll return the URL instead of redirecting
$result = [
'redirect_url' => $authUrl,
'tenant' => $tenant['name'],
'message' => 'Redirect to Facebook OAuth'
];
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
}

View File

@@ -0,0 +1,237 @@
<?php
// src/Controllers/JobProviderController.php
namespace App\Controllers;
use App\Models\JobPosition;
use App\Models\ApplicationModel;
use App\Models\User;
use App\Auth\AuthService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Psr7\Response;
class JobProviderController
{
private $jobPositionModel;
private $applicationModel;
private $userModel;
private $authService;
public function __construct()
{
$this->jobPositionModel = new JobPosition();
$this->applicationModel = new ApplicationModel();
$this->userModel = new User();
$this->authService = new AuthService();
}
public function createPosition(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
// For now, we'll assume the user is authenticated and get their ID from query params or headers
// In a real app, this would be extracted from the JWT in an authentication middleware
$userId = $_GET['user_id'] ?? null; // This is just for demo purposes
if (!$userId) {
$response->getBody()->write(json_encode(['error' => 'User ID is required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
// Verify the user exists and is part of the tenant
$user = $this->userModel->findById($userId);
if (!$user || $user['tenant_id'] !== $tenant['id']) {
$response->getBody()->write(json_encode(['error' => 'User not found or does not belong to this tenant']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$parsedBody = $request->getParsedBody();
// Validate required fields
$requiredFields = ['title', 'description'];
foreach ($requiredFields as $field) {
if (empty($parsedBody[$field])) {
$response->getBody()->write(json_encode(['error' => "$field is required"]));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
}
$positionData = [
'tenant_id' => $tenant['id'],
'title' => $parsedBody['title'],
'description' => $parsedBody['description'],
'location' => $parsedBody['location'] ?? '',
'employment_type' => $parsedBody['employment_type'] ?? 'full_time',
'salary_min' => $parsedBody['salary_min'] ?? null,
'salary_max' => $parsedBody['salary_max'] ?? null,
'posted_by' => $userId,
'status' => $parsedBody['status'] ?? 'draft' // Default to draft, can be published later
];
$positionId = $this->jobPositionModel->create($positionData);
$result = [
'message' => 'Job position created successfully',
'position_id' => $positionId,
'position' => $positionData,
'tenant' => $tenant['name']
];
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
public function updatePosition(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
$positionId = $args['id'] ?? null;
if (!$positionId) {
$response->getBody()->write(json_encode(['error' => 'Position ID is required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
// Verify the position belongs to this tenant
$position = $this->jobPositionModel->findById($positionId, $tenant['id']);
if (!$position) {
$response->getBody()->write(json_encode(['error' => 'Position not found or does not belong to this tenant']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$parsedBody = $request->getParsedBody();
// For this demo, we'll just update the status
if (isset($parsedBody['status'])) {
$validStatuses = ['draft', 'published', 'closed'];
if (!in_array($parsedBody['status'], $validStatuses)) {
$response->getBody()->write(json_encode(['error' => 'Invalid status value']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
$updated = $this->jobPositionModel->updateStatus($positionId, $parsedBody['status'], $tenant['id']);
if (!$updated) {
$response->getBody()->write(json_encode(['error' => 'Failed to update position status']));
return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
}
$result = [
'message' => 'Position status updated successfully',
'position_id' => $positionId,
'new_status' => $parsedBody['status'],
'tenant' => $tenant['name']
];
} else {
$result = [
'message' => 'Nothing to update',
'position_id' => $positionId,
'tenant' => $tenant['name']
];
}
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
public function deletePosition(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
$positionId = $args['id'] ?? null;
if (!$positionId) {
$response->getBody()->write(json_encode(['error' => 'Position ID is required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
// In a real implementation, we would delete the position
// For this demo, we'll just mark it as 'closed'
$updated = $this->jobPositionModel->updateStatus($positionId, 'closed', $tenant['id']);
if (!$updated) {
$response->getBody()->write(json_encode(['error' => 'Position not found or does not belong to this tenant']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$result = [
'message' => 'Position closed successfully',
'position_id' => $positionId,
'tenant' => $tenant['name']
];
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
public function getApplicationsForPosition(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
$positionId = $args['id'] ?? null;
if (!$positionId) {
$response->getBody()->write(json_encode(['error' => 'Position ID is required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
// Verify the position belongs to this tenant
$position = $this->jobPositionModel->findById($positionId, $tenant['id']);
if (!$position) {
$response->getBody()->write(json_encode(['error' => 'Position not found or does not belong to this tenant']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$applications = $this->applicationModel->findByJobPosition($positionId, $tenant['id']);
$result = [
'applications' => $applications,
'position' => $position,
'tenant' => $tenant['name']
];
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
public function updateApplicationStatus(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
$applicationId = $args['id'] ?? null;
if (!$applicationId) {
$response->getBody()->write(json_encode(['error' => 'Application ID is required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
$parsedBody = $request->getParsedBody();
if (!isset($parsedBody['status'])) {
$response->getBody()->write(json_encode(['error' => 'Status is required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
$validStatuses = ['submitted', 'under_review', 'accepted', 'rejected'];
if (!in_array($parsedBody['status'], $validStatuses)) {
$response->getBody()->write(json_encode(['error' => 'Invalid status value']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
$updated = $this->applicationModel->updateStatus($applicationId, $parsedBody['status'], $tenant['id']);
if (!$updated) {
$response->getBody()->write(json_encode(['error' => 'Application not found or does not belong to this tenant']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$result = [
'message' => 'Application status updated successfully',
'application_id' => $applicationId,
'new_status' => $parsedBody['status'],
'tenant' => $tenant['name']
];
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
}

View File

@@ -0,0 +1,176 @@
<?php
// src/Controllers/JobSeekerController.php
namespace App\Controllers;
use App\Models\JobPosition;
use App\Models\ApplicationModel;
use App\Models\User;
use App\Auth\AuthService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Psr7\Response;
class JobSeekerController
{
private $jobPositionModel;
private $applicationModel;
private $authService;
public function __construct()
{
$this->jobPositionModel = new JobPosition();
$this->applicationModel = new ApplicationModel();
$this->authService = new AuthService();
}
public function browsePositions(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
// Get query parameters for filtering
$queryParams = $request->getQueryParams();
$location = $queryParams['location'] ?? null;
$type = $queryParams['type'] ?? null;
$search = $queryParams['search'] ?? null;
// Get all published positions for this tenant
$positions = $this->jobPositionModel->findByTenant($tenant['id'], 'published');
// Apply filters if provided
if ($location) {
$positions = array_filter($positions, function($pos) use ($location) {
return stripos($pos['location'], $location) !== false;
});
}
if ($type) {
$positions = array_filter($positions, function($pos) use ($type) {
return stripos($pos['employment_type'], $type) !== false;
});
}
if ($search) {
$positions = array_filter($positions, function($pos) use ($search) {
return stripos($pos['title'], $search) !== false ||
stripos($pos['description'], $search) !== false;
});
}
$result = [
'positions' => array_values($positions), // Re-index array after filtering
'tenant' => $tenant['name'],
'filters' => [
'location' => $location,
'type' => $type,
'search' => $search
]
];
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
public function getPosition(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
$positionId = $args['id'] ?? null;
if (!$positionId) {
$response->getBody()->write(json_encode(['error' => 'Position ID is required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
$position = $this->jobPositionModel->findById($positionId, $tenant['id']);
if (!$position) {
$response->getBody()->write(json_encode(['error' => 'Position not found']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$result = [
'position' => $position,
'tenant' => $tenant['name']
];
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
public function applyForPosition(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
$positionId = $args['id'] ?? null;
// For now, we'll assume the user is authenticated and get their ID from query params or headers
// In a real app, this would be extracted from the JWT in an authentication middleware
$userId = $_GET['user_id'] ?? null; // This is just for demo purposes
if (!$positionId) {
$response->getBody()->write(json_encode(['error' => 'Position ID is required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
if (!$userId) {
$response->getBody()->write(json_encode(['error' => 'User ID is required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
// Verify that the position exists and belongs to the current tenant
$position = $this->jobPositionModel->findById($positionId, $tenant['id']);
if (!$position) {
$response->getBody()->write(json_encode(['error' => 'Position not found or does not belong to this tenant']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
$parsedBody = $request->getParsedBody();
$resumePath = $parsedBody['resume_path'] ?? '';
$coverLetter = $parsedBody['cover_letter'] ?? '';
// Create application
$applicationData = [
'job_position_id' => $positionId,
'applicant_id' => $userId,
'resume_path' => $resumePath,
'cover_letter' => $coverLetter,
'status' => 'submitted'
];
$applicationId = $this->applicationModel->create($applicationData);
$result = [
'message' => 'Application submitted successfully',
'application_id' => $applicationId,
'position' => $position,
'tenant' => $tenant['name']
];
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
public function getMyApplications(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$tenant = $request->getAttribute('tenant');
// For now, we'll assume the user is authenticated and get their ID from query params or headers
// In a real app, this would be extracted from the JWT in an authentication middleware
$userId = $_GET['user_id'] ?? null; // This is just for demo purposes
if (!$userId) {
$response->getBody()->write(json_encode(['error' => 'User ID is required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
$applications = $this->applicationModel->findByApplicant($userId, $tenant['id']);
$result = [
'applications' => $applications,
'tenant' => $tenant['name'],
'applicant_id' => $userId
];
$response->getBody()->write(json_encode($result));
return $response->withHeader('Content-Type', 'application/json');
}
}

View File

@@ -0,0 +1,36 @@
<?php
// src/Database/DatabaseManager.php
namespace App\Database;
use PDO;
use PDOException;
class DatabaseManager
{
private static ?PDO $pdo = null;
public static function connect(): PDO
{
if (self::$pdo === null) {
$host = $_ENV['DB_HOST'] ?? 'localhost';
$port = $_ENV['DB_PORT'] ?? '5432';
$dbname = $_ENV['DB_NAME'] ?? 'moh_db';
$username = $_ENV['DB_USER'] ?? 'moh_user';
$password = $_ENV['DB_PASSWORD'] ?? 'moh_password';
$dsn = "pgsql:host={$host};port={$port};dbname={$dbname}";
try {
self::$pdo = new PDO($dsn, $username, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
throw new PDOException($e->getMessage(), (int)$e->getCode());
}
}
return self::$pdo;
}
}

View File

@@ -0,0 +1,41 @@
<?php
// src/Middleware/CorsMiddleware.php
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class CorsMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
// Handle preflight requests
if ($request->getMethod() === 'OPTIONS') {
$response = new \Slim\Psr7\Response();
return $this->addCorsHeaders($response);
}
$response = $handler->handle($request);
return $this->addCorsHeaders($response);
}
private function addCorsHeaders(ResponseInterface $response): ResponseInterface
{
$allowedOrigins = $_ENV['ALLOWED_ORIGINS'] ?? 'http://localhost:3000,http://localhost:8080,https://merchantsOfHope.org';
$origins = array_map('trim', explode(',', $allowedOrigins));
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $origins)) {
$response = $response
->withHeader('Access-Control-Allow-Origin', $origin)
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS')
->withHeader('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type, Accept, Origin, Authorization')
->withHeader('Access-Control-Allow-Credentials', 'true');
}
return $response;
}
}

View File

@@ -0,0 +1,28 @@
<?php
// src/Middleware/SecurityMiddleware.php
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class SecurityMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
// Add security headers
$response = $response
->withHeader('X-Frame-Options', 'DENY')
->withHeader('X-Content-Type-Options', 'nosniff')
->withHeader('X-XSS-Protection', '1; mode=block')
->withHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload')
->withHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
->withHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
->withHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';");
return $response;
}
}

View File

@@ -0,0 +1,53 @@
<?php
// src/Middleware/TenantMiddleware.php
namespace App\Middleware;
use App\Models\Tenant;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class TenantMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
// Extract subdomain from the host
$host = $request->getHeaderLine('Host');
$subdomain = $this->extractSubdomain($host);
// If no specific subdomain, assume the main site
if (!$subdomain || $subdomain === 'localhost') {
$subdomain = 'tsys'; // default tenant
}
// Find tenant by subdomain
$tenantModel = new Tenant();
$tenant = $tenantModel->findBySubdomain($subdomain);
if (!$tenant) {
// Handle case where tenant doesn't exist
$response = new \Slim\Psr7\Response();
$response->getBody()->write(json_encode(['error' => 'Tenant not found']));
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
}
// Add tenant to request attributes for use in route handlers
$request = $request->withAttribute('tenant', $tenant);
return $handler->handle($request);
}
private function extractSubdomain(string $host): ?string
{
$hostParts = explode('.', $host);
// For localhost or IP addresses, return as is
if (count($hostParts) === 1 || filter_var($hostParts[0], FILTER_VALIDATE_IP)) {
return $host;
}
// Return the first part (subdomain)
return $hostParts[0];
}
}

View File

@@ -0,0 +1,95 @@
<?php
// src/Models/ApplicationModel.php
namespace App\Models;
use App\Database\DatabaseManager;
use PDO;
class ApplicationModel
{
private $db;
public function __construct()
{
$this->db = DatabaseManager::connect();
}
public function findById(string $id): ?array
{
$stmt = $this->db->prepare('SELECT * FROM applications WHERE id = :id');
$stmt->bindParam(':id', $id);
$stmt->execute();
return $stmt->fetch() ?: null;
}
public function findByJobPosition(string $jobPositionId, string $tenantId): array
{
$stmt = $this->db->prepare('
SELECT a.*, u.first_name, u.last_name, u.email
FROM applications a
JOIN users u ON a.applicant_id = u.id
JOIN job_positions jp ON a.job_position_id = jp.id
WHERE a.job_position_id = :job_position_id AND jp.tenant_id = :tenant_id
ORDER BY a.created_at DESC
');
$stmt->bindParam(':job_position_id', $jobPositionId);
$stmt->bindParam(':tenant_id', $tenantId);
$stmt->execute();
return $stmt->fetchAll();
}
public function findByApplicant(string $applicantId, string $tenantId): array
{
$stmt = $this->db->prepare('
SELECT a.*, jp.title as position_title
FROM applications a
JOIN job_positions jp ON a.job_position_id = jp.id
WHERE a.applicant_id = :applicant_id AND jp.tenant_id = :tenant_id
ORDER BY a.created_at DESC
');
$stmt->bindParam(':applicant_id', $applicantId);
$stmt->bindParam(':tenant_id', $tenantId);
$stmt->execute();
return $stmt->fetchAll();
}
public function create(array $data): string
{
$id = bin2hex(random_bytes(16)); // Simple ID generation for demo
$stmt = $this->db->prepare('
INSERT INTO applications (id, job_position_id, applicant_id, resume_path, cover_letter, status)
VALUES (:id, :job_position_id, :applicant_id, :resume_path, :cover_letter, :status)
RETURNING id
');
$stmt->bindParam(':id', $id);
$stmt->bindParam(':job_position_id', $data['job_position_id']);
$stmt->bindParam(':applicant_id', $data['applicant_id']);
$stmt->bindParam(':resume_path', $data['resume_path']);
$stmt->bindParam(':cover_letter', $data['cover_letter']);
$stmt->bindParam(':status', $data['status']);
$stmt->execute();
$result = $stmt->fetch();
return $result['id'];
}
public function updateStatus(string $id, string $status, string $tenantId): bool
{
$stmt = $this->db->prepare('
UPDATE applications a
SET status = :status, updated_at = CURRENT_TIMESTAMP
FROM job_positions jp
WHERE a.id = :id AND a.job_position_id = jp.id AND jp.tenant_id = :tenant_id
');
$stmt->bindParam(':id', $id);
$stmt->bindParam(':status', $status);
$stmt->bindParam(':tenant_id', $tenantId);
return $stmt->execute();
}
}

View File

@@ -0,0 +1,72 @@
<?php
// src/Models/JobPosition.php
namespace App\Models;
use App\Database\DatabaseManager;
use PDO;
class JobPosition
{
private $db;
public function __construct()
{
$this->db = DatabaseManager::connect();
}
public function findById(string $id, string $tenantId): ?array
{
$stmt = $this->db->prepare('SELECT * FROM job_positions WHERE id = :id AND tenant_id = :tenant_id');
$stmt->bindParam(':id', $id);
$stmt->bindParam(':tenant_id', $tenantId);
$stmt->execute();
return $stmt->fetch() ?: null;
}
public function findByTenant(string $tenantId, string $status = 'published'): array
{
$stmt = $this->db->prepare('SELECT * FROM job_positions WHERE tenant_id = :tenant_id AND status = :status ORDER BY created_at DESC');
$stmt->bindParam(':tenant_id', $tenantId);
$stmt->bindParam(':status', $status);
$stmt->execute();
return $stmt->fetchAll();
}
public function create(array $data): string
{
$id = bin2hex(random_bytes(16)); // Simple ID generation for demo
$stmt = $this->db->prepare('
INSERT INTO job_positions (id, tenant_id, title, description, location, employment_type, salary_min, salary_max, posted_by, status)
VALUES (:id, :tenant_id, :title, :description, :location, :employment_type, :salary_min, :salary_max, :posted_by, :status)
RETURNING id
');
$stmt->bindParam(':id', $id);
$stmt->bindParam(':tenant_id', $data['tenant_id']);
$stmt->bindParam(':title', $data['title']);
$stmt->bindParam(':description', $data['description']);
$stmt->bindParam(':location', $data['location']);
$stmt->bindParam(':employment_type', $data['employment_type']);
$stmt->bindParam(':salary_min', $data['salary_min']);
$stmt->bindParam(':salary_max', $data['salary_max']);
$stmt->bindParam(':posted_by', $data['posted_by']);
$stmt->bindParam(':status', $data['status']);
$stmt->execute();
$result = $stmt->fetch();
return $result['id'];
}
public function updateStatus(string $id, string $status, string $tenantId): bool
{
$stmt = $this->db->prepare('UPDATE job_positions SET status = :status, updated_at = CURRENT_TIMESTAMP WHERE id = :id AND tenant_id = :tenant_id');
$stmt->bindParam(':id', $id);
$stmt->bindParam(':status', $status);
$stmt->bindParam(':tenant_id', $tenantId);
return $stmt->execute();
}
}

View File

@@ -0,0 +1,51 @@
<?php
// src/Models/Tenant.php
namespace App\Models;
use App\Database\DatabaseManager;
use PDO;
class Tenant
{
private $db;
public function __construct()
{
$this->db = DatabaseManager::connect();
}
public function findById(string $id): ?array
{
$stmt = $this->db->prepare('SELECT * FROM tenants WHERE id = :id');
$stmt->bindParam(':id', $id);
$stmt->execute();
return $stmt->fetch() ?: null;
}
public function findBySubdomain(string $subdomain): ?array
{
$stmt = $this->db->prepare('SELECT * FROM tenants WHERE subdomain = :subdomain');
$stmt->bindParam(':subdomain', $subdomain);
$stmt->execute();
return $stmt->fetch() ?: null;
}
public function create(array $data): string
{
$id = bin2hex(random_bytes(16)); // Simple ID generation for demo
$stmt = $this->db->prepare('
INSERT INTO tenants (id, name, subdomain)
VALUES (:id, :name, :subdomain)
RETURNING id
');
$stmt->bindParam(':id', $id);
$stmt->bindParam(':name', $data['name']);
$stmt->bindParam(':subdomain', $data['subdomain']);
$stmt->execute();
$result = $stmt->fetch();
return $result['id'];
}
}

View File

@@ -0,0 +1,81 @@
<?php
// src/Models/User.php
namespace App\Models;
use App\Database\DatabaseManager;
use PDO;
class User
{
private $db;
public function __construct()
{
$this->db = DatabaseManager::connect();
}
public function findById(string $id): ?array
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id');
$stmt->bindParam(':id', $id);
$stmt->execute();
return $stmt->fetch() ?: null;
}
public function findByEmail(string $email): ?array
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email');
$stmt->bindParam(':email', $email);
$stmt->execute();
return $stmt->fetch() ?: null;
}
public function create(array $data): string
{
$id = bin2hex(random_bytes(16)); // Simple ID generation for demo
$hashedPassword = password_hash($data['password'], PASSWORD_DEFAULT);
$stmt = $this->db->prepare('
INSERT INTO users (id, tenant_id, email, password_hash, first_name, last_name, role, provider, provider_id)
VALUES (:id, :tenant_id, :email, :password_hash, :first_name, :last_name, :role, :provider, :provider_id)
RETURNING id
');
$stmt->bindParam(':id', $id);
$stmt->bindParam(':tenant_id', $data['tenant_id']);
$stmt->bindParam(':email', $data['email']);
$stmt->bindParam(':password_hash', $hashedPassword);
$stmt->bindParam(':first_name', $data['first_name']);
$stmt->bindParam(':last_name', $data['last_name']);
$stmt->bindParam(':role', $data['role']);
$stmt->bindParam(':provider', $data['provider']);
$stmt->bindParam(':provider_id', $data['provider_id']);
$stmt->execute();
$result = $stmt->fetch();
return $result['id'];
}
public function authenticate(string $email, string $password): ?array
{
$user = $this->findByEmail($email);
if ($user && password_verify($password, $user['password_hash'])) {
return $user;
}
return null;
}
public function findByProviderId(string $providerId, string $provider): ?array
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE provider_id = :provider_id AND provider = :provider');
$stmt->bindParam(':provider_id', $providerId);
$stmt->bindParam(':provider', $provider);
$stmt->execute();
return $stmt->fetch() ?: null;
}
}

View File

@@ -0,0 +1,46 @@
<?php
// src/Utils/Validator.php
namespace App\Utils;
class Validator
{
public static function validateEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
public static function validateRequired(array $data, array $requiredFields): array
{
$errors = [];
foreach ($requiredFields as $field) {
if (!isset($data[$field]) || trim($data[$field]) === '') {
$errors[] = "$field is required";
}
}
return $errors;
}
public static function sanitizeString(string $string): string
{
return htmlspecialchars(strip_tags(trim($string)), ENT_QUOTES, 'UTF-8');
}
public static function validateUrl(string $url): bool
{
return filter_var($url, FILTER_VALIDATE_URL) !== false;
}
public static function validateLength(string $string, int $min, int $max): bool
{
$length = strlen($string);
return $length >= $min && $length <= $max;
}
public static function validateDate(string $date): bool
{
$d = DateTime::createFromFormat('Y-m-d', $date);
return $d && $d->format('Y-m-d') === $date;
}
}