diff --git a/qwen/hack/composer.json b/qwen/hack/composer.json index dbc56e1..b5f78d2 100644 --- a/qwen/hack/composer.json +++ b/qwen/hack/composer.json @@ -18,7 +18,10 @@ "guzzlehttp/guzzle": "^7.0", "monolog/monolog": "^2.0", "vlucas/phpdotenv": "^5.0", - "php-di/php-di": "^6.0" + "php-di/php-di": "^6.0", + "league/oauth2-client": "^2.0", + "league/oauth2-google": "^4.0", + "league/oauth2-github": "^3.0" }, "require-dev": { "phpunit/phpunit": "^9.0", diff --git a/qwen/hack/public/index.php b/qwen/hack/public/index.php index 1e02653..f94f1ac 100644 --- a/qwen/hack/public/index.php +++ b/qwen/hack/public/index.php @@ -7,6 +7,8 @@ require_once __DIR__ . '/bootstrap.php'; use App\Controllers\HomeController; +use App\Middleware\TenantMiddleware; +use App\Services\TenantResolver; use Slim\Factory\AppFactory; use Slim\Middleware\ContentLengthMiddleware; use Slim\Routing\RouteCollectorProxy; @@ -20,9 +22,18 @@ $app->addBodyParsingMiddleware(); $app->addRoutingMiddleware(); $app->add(new ContentLengthMiddleware()); +// Add tenant middleware +$tenantResolver = new TenantResolver(); +$tenantMiddleware = new TenantMiddleware($tenantResolver); +$app->add($tenantMiddleware); + // Define routes $app->get('/', [HomeController::class, 'index']); +// Social login routes (not under API group to avoid tenant isolation) +$app->get('/auth/google/callback', [App\Controllers\AuthController::class, 'googleCallback']); +$app->get('/auth/github/callback', [App\Controllers\AuthController::class, 'githubCallback']); + // Group routes for API $app->group('/api', function (RouteCollectorProxy $group) { // Authentication routes diff --git a/qwen/hack/src/Controllers/ApplicationController.php b/qwen/hack/src/Controllers/ApplicationController.php new file mode 100644 index 0000000..8a7d90e --- /dev/null +++ b/qwen/hack/src/Controllers/ApplicationController.php @@ -0,0 +1,15 @@ +getBody()->write(json_encode(['message' => 'Apply for job endpoint'])); + return $response->withHeader('Content-Type', 'application/json'); + } +} \ No newline at end of file diff --git a/qwen/hack/src/Controllers/AuthController.php b/qwen/hack/src/Controllers/AuthController.php index a209e99..60aad6c 100644 --- a/qwen/hack/src/Controllers/AuthController.php +++ b/qwen/hack/src/Controllers/AuthController.php @@ -2,26 +2,166 @@ namespace App\Controllers; +use App\Services\AuthService; +use Firebase\JWT\JWT; +use Firebase\JWT\Key; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; class AuthController { + private AuthService $authService; + + public function __construct() + { + $this->authService = new AuthService(); + } + public function login(Request $request, Response $response): Response { - $response->getBody()->write(json_encode(['message' => 'Login endpoint'])); + // For traditional login, you would validate credentials here + // For OIDC/social login, redirect to appropriate provider + + $params = $request->getParsedBody(); + $provider = $params['provider'] ?? null; + + switch ($provider) { + case 'google': + $authUrl = $this->authService->getGoogleAuthorizationUrl(); + $response->getBody()->write(json_encode(['redirect_url' => $authUrl])); + break; + case 'github': + $authUrl = $this->authService->getGithubAuthorizationUrl(); + $response->getBody()->write(json_encode(['redirect_url' => $authUrl])); + break; + default: + // Traditional login + $email = $params['email'] ?? ''; + $password = $params['password'] ?? ''; + + // Validate credentials (simplified) + if ($this->validateCredentials($email, $password)) { + $token = $this->generateToken($email); + $response->getBody()->write(json_encode(['token' => $token, 'message' => 'Login successful'])); + } else { + $response = $response->withStatus(401); + $response->getBody()->write(json_encode(['error' => 'Invalid credentials'])); + } + break; + } + return $response->withHeader('Content-Type', 'application/json'); } public function logout(Request $request, Response $response): Response { - $response->getBody()->write(json_encode(['message' => 'Logout endpoint'])); + // Clear any tokens, sessions, etc. + $response->getBody()->write(json_encode(['message' => 'Logout successful'])); return $response->withHeader('Content-Type', 'application/json'); } public function register(Request $request, Response $response): Response { - $response->getBody()->write(json_encode(['message' => 'Register endpoint'])); + $params = $request->getParsedBody(); + $email = $params['email'] ?? ''; + $name = $params['name'] ?? ''; + $password = $params['password'] ?? ''; + + // Validate input + if (empty($email) || empty($name) || empty($password)) { + $response = $response->withStatus(400); + $response->getBody()->write(json_encode(['error' => 'All fields are required'])); + return $response->withHeader('Content-Type', 'application/json'); + } + + // Register user (simplified) + if ($this->registerUser($email, $name, $password)) { + $token = $this->generateToken($email); + $response->getBody()->write(json_encode(['token' => $token, 'message' => 'Registration successful'])); + } else { + $response = $response->withStatus(500); + $response->getBody()->write(json_encode(['error' => 'Registration failed'])); + } + return $response->withHeader('Content-Type', 'application/json'); } + + public function googleCallback(Request $request, Response $response): Response + { + try { + $userData = $this->authService->handleGoogleCallback($request); + + // Check if user exists, create if not + $user = $this->findOrCreateUser($userData); + + // Generate JWT token + $token = $this->generateToken($user['email']); + + // Redirect to frontend with token + $redirectUrl = $_ENV['FRONTEND_URL'] ?? 'http://localhost:3000' . "/auth/callback?token={$token}"; + return $response->withHeader('Location', $redirectUrl)->withStatus(302); + } catch (\Exception $e) { + $response = $response->withStatus(500); + $response->getBody()->write(json_encode(['error' => $e->getMessage()])); + return $response->withHeader('Content-Type', 'application/json'); + } + } + + public function githubCallback(Request $request, Response $response): Response + { + try { + $userData = $this->authService->handleGithubCallback($request); + + // Check if user exists, create if not + $user = $this->findOrCreateUser($userData); + + // Generate JWT token + $token = $this->generateToken($user['email']); + + // Redirect to frontend with token + $redirectUrl = $_ENV['FRONTEND_URL'] ?? 'http://localhost:3000' . "/auth/callback?token={$token}"; + return $response->withHeader('Location', $redirectUrl)->withStatus(302); + } catch (\Exception $e) { + $response = $response->withStatus(500); + $response->getBody()->write(json_encode(['error' => $e->getMessage()])); + return $response->withHeader('Content-Type', 'application/json'); + } + } + + private function validateCredentials(string $email, string $password): bool + { + // This would normally check against a database + // For now, just return true for demo purposes + return !empty($email) && !empty($password); + } + + private function registerUser(string $email, string $name, string $password): bool + { + // This would normally store the user in a database + // For now, just return true for demo purposes + return true; + } + + private function findOrCreateUser(array $userData): array + { + // This would normally query the database to find or create a user + // For now, return mock user data + return [ + 'id' => 1, + 'email' => $userData['email'], + 'name' => $userData['name'], + ]; + } + + private function generateToken(string $email): string + { + $payload = [ + 'iss' => 'MerchantsOfHope', // Issuer + 'sub' => $email, // Subject + 'iat' => time(), // Issued at + 'exp' => time() + 3600 // Expiration time (1 hour) + ]; + + return JWT::encode($payload, $_ENV['JWT_SECRET'], 'HS256'); + } } \ No newline at end of file diff --git a/qwen/hack/src/Controllers/JobController.php b/qwen/hack/src/Controllers/JobController.php index 1f063cc..6a168c2 100644 --- a/qwen/hack/src/Controllers/JobController.php +++ b/qwen/hack/src/Controllers/JobController.php @@ -2,47 +2,220 @@ namespace App\Controllers; +use App\Models\Job; +use App\Services\JobService; +use PDO; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; class JobController { + private JobService $jobService; + + public function __construct() + { + // In a real application, this would be injected via DI container + $this->jobService = new JobService($this->getDbConnection()); + } + public function listJobs(Request $request, Response $response): Response { - $response->getBody()->write(json_encode(['message' => 'List jobs endpoint'])); + // Extract filters from query parameters + $params = $request->getQueryParams(); + $filters = []; + + if (isset($params['location'])) { + $filters['location'] = $params['location']; + } + + if (isset($params['keywords'])) { + $filters['keywords'] = $params['keywords']; + } + + // Get tenant from request attribute (set by middleware) + $tenant = $request->getAttribute('tenant'); + + $jobs = $this->jobService->getAllJobs($tenant, $filters); + + $jobsArray = []; + foreach ($jobs as $job) { + $jobsArray[] = [ + 'id' => $job->getId(), + 'title' => $job->getTitle(), + 'description' => $job->getDescription(), + 'location' => $job->getLocation(), + 'employment_type' => $job->getEmploymentType(), + 'created_at' => $job->getCreatedAt()->format('Y-m-d H:i:s') + ]; + } + + $response->getBody()->write(json_encode($jobsArray)); return $response->withHeader('Content-Type', 'application/json'); } public function getJob(Request $request, Response $response, array $args): Response { - $jobId = $args['id']; - $response->getBody()->write(json_encode(['message' => 'Get job endpoint', 'id' => $jobId])); + $jobId = (int)$args['id']; + + // Get tenant from request attribute (set by middleware) + $tenant = $request->getAttribute('tenant'); + + $job = $this->jobService->getJobById($jobId, $tenant); + + if (!$job) { + $response = $response->withStatus(404); + $response->getBody()->write(json_encode(['error' => 'Job not found'])); + return $response->withHeader('Content-Type', 'application/json'); + } + + $jobData = [ + 'id' => $job->getId(), + 'title' => $job->getTitle(), + 'description' => $job->getDescription(), + 'location' => $job->getLocation(), + 'employment_type' => $job->getEmploymentType(), + 'created_at' => $job->getCreatedAt()->format('Y-m-d H:i:s'), + 'updated_at' => $job->getUpdatedAt()->format('Y-m-d H:i:s') + ]; + + $response->getBody()->write(json_encode($jobData)); return $response->withHeader('Content-Type', 'application/json'); } public function myJobs(Request $request, Response $response): Response { - $response->getBody()->write(json_encode(['message' => 'My jobs endpoint'])); + // Get tenant from request attribute (set by middleware) + $tenant = $request->getAttribute('tenant'); + + // For job providers, get jobs they've posted + // This would typically involve checking user permissions + // For now, just return all jobs for the tenant + + $jobs = $this->jobService->getAllJobs($tenant); + + $jobsArray = []; + foreach ($jobs as $job) { + $jobsArray[] = [ + 'id' => $job->getId(), + 'title' => $job->getTitle(), + 'description' => $job->getDescription(), + 'location' => $job->getLocation(), + 'employment_type' => $job->getEmploymentType(), + 'created_at' => $job->getCreatedAt()->format('Y-m-d H:i:s') + ]; + } + + $response->getBody()->write(json_encode($jobsArray)); return $response->withHeader('Content-Type', 'application/json'); } public function createJob(Request $request, Response $response): Response { - $response->getBody()->write(json_encode(['message' => 'Create job endpoint'])); - return $response->withHeader('Content-Type', 'application/json'); + $params = $request->getParsedBody(); + + // Validate required fields + $required = ['title', 'description', 'location', 'employment_type']; + foreach ($required as $field) { + if (empty($params[$field])) { + $response = $response->withStatus(400); + $response->getBody()->write(json_encode(['error' => "Missing required field: {$field}"])); + return $response->withHeader('Content-Type', 'application/json'); + } + } + + // Get tenant from request attribute (set by middleware) + $tenant = $request->getAttribute('tenant'); + $tenantId = $tenant ? $tenant->getId() : 'default'; + + // Create job + $job = new Job( + id: 0, // Will be set by database + title: $params['title'], + description: $params['description'], + location: $params['location'], + employmentType: $params['employment_type'], + tenantId: $tenantId + ); + + $result = $this->jobService->createJob($job); + + if ($result) { + $response->getBody()->write(json_encode(['message' => 'Job created successfully'])); + return $response->withHeader('Content-Type', 'application/json')->withStatus(201); + } else { + $response = $response->withStatus(500); + $response->getBody()->write(json_encode(['error' => 'Failed to create job'])); + return $response->withHeader('Content-Type', 'application/json'); + } } public function updateJob(Request $request, Response $response, array $args): Response { - $jobId = $args['id']; - $response->getBody()->write(json_encode(['message' => 'Update job endpoint', 'id' => $jobId])); - return $response->withHeader('Content-Type', 'application/json'); + $jobId = (int)$args['id']; + $params = $request->getParsedBody(); + + // Get tenant from request attribute (set by middleware) + $tenant = $request->getAttribute('tenant'); + + // First get the existing job to ensure it belongs to this tenant + $existingJob = $this->jobService->getJobById($jobId, $tenant); + + if (!$existingJob) { + $response = $response->withStatus(404); + $response->getBody()->write(json_encode(['error' => 'Job not found or does not belong to your tenant'])); + return $response->withHeader('Content-Type', 'application/json'); + } + + // Update the job with new data + $existingJob->setTitle($params['title'] ?? $existingJob->getTitle()); + $existingJob->setDescription($params['description'] ?? $existingJob->getDescription()); + $existingJob->setLocation($params['location'] ?? $existingJob->getLocation()); + $existingJob->setEmploymentType($params['employment_type'] ?? $existingJob->getEmploymentType()); + + $result = $this->jobService->updateJob($existingJob); + + if ($result) { + $response->getBody()->write(json_encode(['message' => 'Job updated successfully'])); + return $response->withHeader('Content-Type', 'application/json'); + } else { + $response = $response->withStatus(500); + $response->getBody()->write(json_encode(['error' => 'Failed to update job'])); + return $response->withHeader('Content-Type', 'application/json'); + } } public function deleteJob(Request $request, Response $response, array $args): Response { - $jobId = $args['id']; - $response->getBody()->write(json_encode(['message' => 'Delete job endpoint', 'id' => $jobId])); - return $response->withHeader('Content-Type', 'application/json'); + $jobId = (int)$args['id']; + + // Get tenant from request attribute (set by middleware) + $tenant = $request->getAttribute('tenant'); + + $result = $this->jobService->deleteJob($jobId, $tenant); + + if ($result) { + $response->getBody()->write(json_encode(['message' => 'Job deleted successfully'])); + return $response->withHeader('Content-Type', 'application/json'); + } else { + $response = $response->withStatus(404); + $response->getBody()->write(json_encode(['error' => 'Job not found or does not belong to your tenant'])); + return $response->withHeader('Content-Type', 'application/json'); + } + } + + private function getDbConnection(): PDO + { + // In a real application, this would be configured properly + $host = $_ENV['DB_HOST'] ?? 'localhost'; + $dbname = $_ENV['DB_NAME'] ?? 'moh'; + $username = $_ENV['DB_USER'] ?? 'moh_user'; + $password = $_ENV['DB_PASS'] ?? 'moh_password'; + $port = $_ENV['DB_PORT'] ?? '5432'; + + $dsn = "pgsql:host={$host};port={$port};dbname={$dbname};"; + return new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); } } \ No newline at end of file diff --git a/qwen/hack/src/Middleware/TenantMiddleware.php b/qwen/hack/src/Middleware/TenantMiddleware.php new file mode 100644 index 0000000..fcea700 --- /dev/null +++ b/qwen/hack/src/Middleware/TenantMiddleware.php @@ -0,0 +1,52 @@ +tenantResolver = $tenantResolver; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + // Resolve the current tenant + $tenant = $this->tenantResolver->resolveTenant($request); + + // Attach the tenant to the request attributes + $request = $request->withAttribute('tenant', $tenant); + + // If we have a tenant, verify it's active + if ($tenant !== null) { + if (!$tenant->getIsActive()) { + $response = new Response(); + $response->getBody()->write(json_encode([ + 'error' => 'Tenant is inactive', + 'message' => 'This tenant account is currently inactive.' + ])); + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus(403); + } + + // Set tenant-specific headers + $response = $handler->handle($request); + return $response->withAddedHeader('X-Tenant-ID', $tenant->getId()); + } + + // If no tenant found, we might want to return an error + // Or provide a default experience + $response = $handler->handle($request); + return $response->withAddedHeader('X-Tenant-ID', 'default'); + } +} \ No newline at end of file diff --git a/qwen/hack/src/Models/Job.php b/qwen/hack/src/Models/Job.php new file mode 100644 index 0000000..8f230ef --- /dev/null +++ b/qwen/hack/src/Models/Job.php @@ -0,0 +1,101 @@ +id = $id; + $this->title = $title; + $this->description = $description; + $this->location = $location; + $this->employmentType = $employmentType; + $this->tenantId = $tenantId; + $this->createdAt = new DateTime(); + $this->updatedAt = new DateTime(); + } + + // Getters + public function getId(): int + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getLocation(): string + { + return $this->location; + } + + public function getEmploymentType(): string + { + return $this->employmentType; + } + + public function getTenantId(): string + { + return $this->tenantId; + } + + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTime + { + return $this->updatedAt; + } + + // Setters + public function setTitle(string $title): void + { + $this->title = $title; + $this->updatedAt = new DateTime(); + } + + public function setDescription(string $description): void + { + $this->description = $description; + $this->updatedAt = new DateTime(); + } + + public function setLocation(string $location): void + { + $this->location = $location; + $this->updatedAt = new DateTime(); + } + + public function setEmploymentType(string $employmentType): void + { + $this->employmentType = $employmentType; + $this->updatedAt = new DateTime(); + } +} \ No newline at end of file diff --git a/qwen/hack/src/Models/Tenant.php b/qwen/hack/src/Models/Tenant.php new file mode 100644 index 0000000..bc42db2 --- /dev/null +++ b/qwen/hack/src/Models/Tenant.php @@ -0,0 +1,79 @@ +id = $id; + $this->name = $name; + $this->subdomain = $subdomain; + $this->isActive = $isActive; + $this->createdAt = new DateTime(); + $this->updatedAt = new DateTime(); + } + + // Getters + public function getId(): string + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getSubdomain(): string + { + return $this->subdomain; + } + + public function getIsActive(): bool + { + return $this->isActive; + } + + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTime + { + return $this->updatedAt; + } + + // Setters + public function setName(string $name): void + { + $this->name = $name; + $this->updatedAt = new DateTime(); + } + + public function setSubdomain(string $subdomain): void + { + $this->subdomain = $subdomain; + $this->updatedAt = new DateTime(); + } + + public function setIsActive(bool $isActive): void + { + $this->isActive = $isActive; + $this->updatedAt = new DateTime(); + } +} \ No newline at end of file diff --git a/qwen/hack/src/Models/User.php b/qwen/hack/src/Models/User.php new file mode 100644 index 0000000..d95e510 --- /dev/null +++ b/qwen/hack/src/Models/User.php @@ -0,0 +1,87 @@ +id = $id; + $this->email = $email; + $this->name = $name; + $this->role = $role; + $this->tenantId = $tenantId; + $this->createdAt = new DateTime(); + $this->updatedAt = new DateTime(); + } + + // Getters + public function getId(): int + { + return $this->id; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getName(): string + { + return $this->name; + } + + public function getRole(): string + { + return $this->role; + } + + public function getTenantId(): string + { + return $this->tenantId; + } + + public function getCreatedAt(): DateTime + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTime + { + return $this->updatedAt; + } + + // Setters + public function setEmail(string $email): void + { + $this->email = $email; + $this->updatedAt = new DateTime(); + } + + public function setName(string $name): void + { + $this->name = $name; + $this->updatedAt = new DateTime(); + } + + public function setRole(string $role): void + { + $this->role = $role; + $this->updatedAt = new DateTime(); + } +} \ No newline at end of file diff --git a/qwen/hack/src/Services/AuthService.php b/qwen/hack/src/Services/AuthService.php new file mode 100644 index 0000000..8d5649e --- /dev/null +++ b/qwen/hack/src/Services/AuthService.php @@ -0,0 +1,88 @@ +googleProvider = new Google([ + 'clientId' => $_ENV['GOOGLE_CLIENT_ID'] ?? '', + 'clientSecret' => $_ENV['GOOGLE_CLIENT_SECRET'] ?? '', + 'redirectUri' => $_ENV['APP_URL'] . '/auth/google/callback' ?? 'http://localhost:18000/auth/google/callback', + ]); + + $this->githubProvider = new Github([ + 'clientId' => $_ENV['GITHUB_CLIENT_ID'] ?? '', + 'clientSecret' => $_ENV['GITHUB_CLIENT_SECRET'] ?? '', + 'redirectUri' => $_ENV['APP_URL'] . '/auth/github/callback' ?? 'http://localhost:18000/auth/github/callback', + ]); + } + + public function getGoogleAuthorizationUrl(): string + { + return $this->googleProvider->getAuthorizationUrl([ + 'scope' => ['email', 'profile'] + ]); + } + + public function getGithubAuthorizationUrl(): string + { + return $this->githubProvider->getAuthorizationUrl([ + 'scope' => ['user:email'] + ]); + } + + public function handleGoogleCallback(ServerRequestInterface $request): array + { + $code = $request->getQueryParams()['code'] ?? null; + + if (!$code) { + throw new \Exception('No authorization code received from Google'); + } + + $token = $this->googleProvider->getAccessToken('authorization_code', [ + 'code' => $code + ]); + + $user = $this->googleProvider->getResourceOwner($token); + + return [ + 'provider' => 'google', + 'id' => $user->getId(), + 'email' => $user->getEmail(), + 'name' => $user->getName(), + 'avatar' => $user->getAvatar(), + ]; + } + + public function handleGithubCallback(ServerRequestInterface $request): array + { + $code = $request->getQueryParams()['code'] ?? null; + + if (!$code) { + throw new \Exception('No authorization code received from GitHub'); + } + + $token = $this->githubProvider->getAccessToken('authorization_code', [ + 'code' => $code + ]); + + $user = $this->githubProvider->getResourceOwner($token); + + return [ + 'provider' => 'github', + 'id' => $user->getId(), + 'email' => $user->getEmail(), + 'name' => $user->getName(), + 'avatar' => $user->getAvatarUrl(), + ]; + } +} \ No newline at end of file diff --git a/qwen/hack/src/Services/JobService.php b/qwen/hack/src/Services/JobService.php new file mode 100644 index 0000000..1ed8cdf --- /dev/null +++ b/qwen/hack/src/Services/JobService.php @@ -0,0 +1,143 @@ +db = $db; + } + + /** + * Get all jobs for the current tenant + */ + public function getAllJobs(?Tenant $tenant, ?array $filters = null): array + { + $tenantId = $tenant ? $tenant->getId() : 'default'; + + $sql = "SELECT * FROM jobs WHERE tenant_id = :tenant_id AND status = 'active'"; + + // Add filters if provided + $params = [':tenant_id' => $tenantId]; + + if ($filters) { + if (isset($filters['location'])) { + $sql .= " AND location LIKE :location"; + $params[':location'] = '%' . $filters['location'] . '%'; + } + + if (isset($filters['keywords'])) { + $sql .= " AND (title LIKE :keywords OR description LIKE :keywords)"; + $params[':keywords'] = '%' . $filters['keywords'] . '%'; + } + } + + $sql .= " ORDER BY created_at DESC"; + + $stmt = $this->db->prepare($sql); + $stmt->execute($params); + + $jobs = []; + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $jobs[] = new Job( + id: (int)$row['id'], + title: $row['title'], + description: $row['description'], + location: $row['location'], + employmentType: $row['employment_type'], + tenantId: $row['tenant_id'] + ); + } + + return $jobs; + } + + /** + * Get a specific job by ID for the current tenant + */ + public function getJobById(int $jobId, ?Tenant $tenant): ?Job + { + $tenantId = $tenant ? $tenant->getId() : 'default'; + + $sql = "SELECT * FROM jobs WHERE id = :id AND tenant_id = :tenant_id"; + $stmt = $this->db->prepare($sql); + $stmt->execute([ + ':id' => $jobId, + ':tenant_id' => $tenantId + ]); + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + + return new Job( + id: (int)$row['id'], + title: $row['title'], + description: $row['description'], + location: $row['location'], + employmentType: $row['employment_type'], + tenantId: $row['tenant_id'] + ); + } + + /** + * Create a new job posting for the tenant + */ + public function createJob(Job $job): bool + { + $sql = "INSERT INTO jobs (title, description, location, employment_type, tenant_id) VALUES (:title, :description, :location, :employment_type, :tenant_id)"; + $stmt = $this->db->prepare($sql); + + return $stmt->execute([ + ':title' => $job->getTitle(), + ':description' => $job->getDescription(), + ':location' => $job->getLocation(), + ':employment_type' => $job->getEmploymentType(), + ':tenant_id' => $job->getTenantId() + ]); + } + + /** + * Update an existing job for the tenant + */ + public function updateJob(Job $job): bool + { + $sql = "UPDATE jobs SET title = :title, description = :description, location = :location, employment_type = :employment_type, updated_at = NOW() WHERE id = :id AND tenant_id = :tenant_id"; + $stmt = $this->db->prepare($sql); + + return $stmt->execute([ + ':id' => $job->getId(), + ':title' => $job->getTitle(), + ':description' => $job->getDescription(), + ':location' => $job->getLocation(), + ':employment_type' => $job->getEmploymentType(), + ':tenant_id' => $job->getTenantId() + ]); + } + + /** + * Delete a job for the tenant + */ + public function deleteJob(int $jobId, ?Tenant $tenant): bool + { + $tenantId = $tenant ? $tenant->getId() : 'default'; + + $sql = "DELETE FROM jobs WHERE id = :id AND tenant_id = :tenant_id"; + $stmt = $this->db->prepare($sql); + + $result = $stmt->execute([ + ':id' => $jobId, + ':tenant_id' => $tenantId + ]); + + return $result && $stmt->rowCount() > 0; + } +} \ No newline at end of file diff --git a/qwen/hack/src/Services/TenantResolver.php b/qwen/hack/src/Services/TenantResolver.php new file mode 100644 index 0000000..af14e37 --- /dev/null +++ b/qwen/hack/src/Services/TenantResolver.php @@ -0,0 +1,55 @@ +getUri()->getHost(); + $path = $request->getUri()->getPath(); + + // Try to extract tenant from subdomain + // Format: tenant.merchantsofhope.org + $hostParts = explode('.', $host); + if (count($hostParts) >= 3) { + $subdomain = $hostParts[0]; + return $this->findTenantBySubdomain($subdomain); + } + + // Alternatively, look for tenant in path + // Format: merchantsofhope.org/tenant-name + $pathParts = explode('/', trim($path, '/')); + if (!empty($pathParts[0])) { + return $this->findTenantBySubdomain($pathParts[0]); + } + + // Default to a global tenant if none found + return null; + } + + /** + * Find tenant by subdomain in the database + */ + private function findTenantBySubdomain(string $subdomain): ?Tenant + { + // This would normally query the database + // For now, we'll return a mock tenant if subdomain exists + if (!empty($subdomain)) { + return new Tenant( + id: 'tenant-' . $subdomain, + name: ucfirst($subdomain) . ' Tenant', + subdomain: $subdomain, + isActive: true + ); + } + + return null; + } +} \ No newline at end of file diff --git a/qwen/python/merchants_of_hope/api/v1/applications.py b/qwen/python/merchants_of_hope/api/v1/applications.py index 056764f..f6f051c 100644 --- a/qwen/python/merchants_of_hope/api/v1/applications.py +++ b/qwen/python/merchants_of_hope/api/v1/applications.py @@ -1,7 +1,7 @@ """ Applications API routes """ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Request from typing import List from pydantic import BaseModel from sqlalchemy.orm import Session @@ -35,101 +35,107 @@ class ApplicationResponse(BaseModel): from_attributes = True @router.get("/", response_model=List[ApplicationResponse]) -async def get_applications(skip: int = 0, limit: int = 100): - """Get all applications""" - db = SessionLocal() - try: - applications = db.query(Application).offset(skip).limit(limit).all() - return applications - finally: - db.close() +async def get_applications(skip: int = 0, limit: int = 100, db: Session = Depends(SessionLocal), request: Request = None): + """Get all applications for the current tenant""" + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + + # Get applications for jobs in the current tenant or applications by users in the current tenant + applications = db.query(Application).join(JobPosting).filter( + (JobPosting.tenant_id == tenant_id) | (Application.user_id.in_( + db.query(User.id).filter(User.tenant_id == tenant_id) + )) + ).offset(skip).limit(limit).all() + return applications @router.get("/{application_id}", response_model=ApplicationResponse) -async def get_application(application_id: int): +async def get_application(application_id: int, db: Session = Depends(SessionLocal), request: Request = None): """Get a specific application""" - db = SessionLocal() - try: - application = db.query(Application).filter(Application.id == application_id).first() - if not application: - raise HTTPException(status_code=404, detail="Application not found") - return application - finally: - db.close() + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + + application = db.query(Application).join(JobPosting).filter( + Application.id == application_id, + (JobPosting.tenant_id == tenant_id) | (Application.user_id.in_( + db.query(User.id).filter(User.tenant_id == tenant_id) + )) + ).first() + if not application: + raise HTTPException(status_code=404, detail="Application not found") + return application @router.post("/", response_model=ApplicationResponse) -async def create_application(application: ApplicationCreate, user_id: int = 1): # In real app, get from auth context +async def create_application(application: ApplicationCreate, db: Session = Depends(SessionLocal), request: Request = None, user_id: int = 1): # In real app, get from auth context """Create a new job application""" - db = SessionLocal() - try: - # Verify user exists and has permission to apply - user = db.query(User).filter(User.id == user_id).first() - if not user or user.role != "job_seeker": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only job seekers can apply for jobs" - ) - - # Verify job posting exists and is active - job_posting = db.query(JobPosting).filter( - JobPosting.id == application.job_posting_id, - JobPosting.is_active == True - ).first() - if not job_posting: - raise HTTPException(status_code=404, detail="Job posting not found or inactive") - - # Verify resume exists and belongs to user - resume = db.query(Resume).filter( - Resume.id == application.resume_id, - Resume.user_id == user_id - ).first() - if not resume: - raise HTTPException(status_code=404, detail="Resume not found") - - db_application = Application( - user_id=user_id, - job_posting_id=application.job_posting_id, - resume_id=application.resume_id, - cover_letter=application.cover_letter + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + + # Verify user exists and has permission to apply + user = db.query(User).filter( + User.id == user_id, + User.tenant_id == tenant_id # Make sure user belongs to current tenant + ).first() + if not user or user.role != "job_seeker": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only job seekers can apply for jobs" ) - db.add(db_application) - db.commit() - db.refresh(db_application) - return db_application - finally: - db.close() + + # Verify job posting exists and is active and belongs to the same tenant + job_posting = db.query(JobPosting).filter( + JobPosting.id == application.job_posting_id, + JobPosting.is_active == True, + JobPosting.tenant_id == tenant_id # Ensure job belongs to current tenant + ).first() + if not job_posting: + raise HTTPException(status_code=404, detail="Job posting not found or inactive or not in your tenant") + + # Verify resume exists and belongs to user + resume = db.query(Resume).filter( + Resume.id == application.resume_id, + Resume.user_id == user_id + ).first() + if not resume: + raise HTTPException(status_code=404, detail="Resume not found") + + db_application = Application( + user_id=user_id, + job_posting_id=application.job_posting_id, + resume_id=application.resume_id, + cover_letter=application.cover_letter + ) + db.add(db_application) + db.commit() + db.refresh(db_application) + return db_application @router.put("/{application_id}", response_model=ApplicationResponse) -async def update_application(application_id: int, app_update: ApplicationUpdate): +async def update_application(application_id: int, app_update: ApplicationUpdate, db: Session = Depends(SessionLocal)): """Update an application""" - db = SessionLocal() - try: - db_application = db.query(Application).filter(Application.id == application_id).first() - if not db_application: - raise HTTPException(status_code=404, detail="Application not found") - - # Update fields if provided - if app_update.status is not None: - db_application.status = app_update.status.value - if app_update.cover_letter is not None: - db_application.cover_letter = app_update.cover_letter - - db.commit() - db.refresh(db_application) - return db_application - finally: - db.close() + db_application = db.query(Application).filter(Application.id == application_id).first() + if not db_application: + raise HTTPException(status_code=404, detail="Application not found") + + # Update fields if provided + if app_update.status is not None: + db_application.status = app_update.status.value + if app_update.cover_letter is not None: + db_application.cover_letter = app_update.cover_letter + + db.commit() + db.refresh(db_application) + return db_application @router.delete("/{application_id}") -async def delete_application(application_id: int): +async def delete_application(application_id: int, db: Session = Depends(SessionLocal)): """Delete an application""" - db = SessionLocal() - try: - db_application = db.query(Application).filter(Application.id == application_id).first() - if not db_application: - raise HTTPException(status_code=404, detail="Application not found") - - db.delete(db_application) - db.commit() - return {"message": "Application deleted successfully"} - finally: - db.close() \ No newline at end of file + db_application = db.query(Application).filter(Application.id == application_id).first() + if not db_application: + raise HTTPException(status_code=404, detail="Application not found") + + db.delete(db_application) + db.commit() + return {"message": "Application deleted successfully"} \ No newline at end of file diff --git a/qwen/python/merchants_of_hope/api/v1/jobs.py b/qwen/python/merchants_of_hope/api/v1/jobs.py index 28b1b59..5af32f3 100644 --- a/qwen/python/merchants_of_hope/api/v1/jobs.py +++ b/qwen/python/merchants_of_hope/api/v1/jobs.py @@ -1,7 +1,7 @@ """ Jobs API routes """ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Request from typing import List from pydantic import BaseModel from sqlalchemy.orm import Session @@ -49,18 +49,29 @@ class JobResponse(BaseModel): from_attributes = True @router.get("/", response_model=List[JobResponse]) -async def get_jobs(skip: int = 0, limit: int = 100, is_active: bool = True, db: Session = Depends(SessionLocal)): - """Get all jobs""" - query = db.query(JobPosting) +async def get_jobs(skip: int = 0, limit: int = 100, is_active: bool = True, db: Session = Depends(SessionLocal), request: Request = None): + """Get all jobs for the current tenant""" + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + + query = db.query(JobPosting).filter(JobPosting.tenant_id == tenant_id) if is_active is not None: query = query.filter(JobPosting.is_active == is_active) jobs = query.offset(skip).limit(limit).all() return jobs @router.get("/{job_id}", response_model=JobResponse) -async def get_job(job_id: int, db: Session = Depends(SessionLocal)): +async def get_job(job_id: int, db: Session = Depends(SessionLocal), request: Request = None): """Get a specific job""" - job = db.query(JobPosting).filter(JobPosting.id == job_id).first() + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + + job = db.query(JobPosting).filter( + JobPosting.id == job_id, + JobPosting.tenant_id == tenant_id # Ensure job belongs to current tenant + ).first() if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.is_active: @@ -68,10 +79,17 @@ async def get_job(job_id: int, db: Session = Depends(SessionLocal)): return job @router.post("/", response_model=JobResponse) -async def create_job(job: JobCreate, db: Session = Depends(SessionLocal), user_id: int = 1): # In real app, get from auth context +async def create_job(job: JobCreate, db: Session = Depends(SessionLocal), request: Request = None, user_id: int = 1): # In real app, get from auth context """Create a new job posting""" + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + # Verify user exists and has permission to create job postings - user = db.query(User).filter(User.id == user_id).first() + user = db.query(User).filter( + User.id == user_id, + User.tenant_id == tenant_id # Ensure user belongs to current tenant + ).first() if not user or user.role not in ["job_provider", "admin"]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -86,7 +104,7 @@ async def create_job(job: JobCreate, db: Session = Depends(SessionLocal), user_i salary_min=job.salary_min, salary_max=job.salary_max, is_remote=job.is_remote, - tenant_id=user.tenant_id, # Use user's tenant + tenant_id=tenant_id, # Use current tenant created_by_user_id=user_id ) db.add(db_job) @@ -95,9 +113,16 @@ async def create_job(job: JobCreate, db: Session = Depends(SessionLocal), user_i return db_job @router.put("/{job_id}", response_model=JobResponse) -async def update_job(job_id: int, job_update: JobUpdate, db: Session = Depends(SessionLocal)): +async def update_job(job_id: int, job_update: JobUpdate, db: Session = Depends(SessionLocal), request: Request = None): """Update a job posting""" - db_job = db.query(JobPosting).filter(JobPosting.id == job_id).first() + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + + db_job = db.query(JobPosting).filter( + JobPosting.id == job_id, + JobPosting.tenant_id == tenant_id # Ensure job belongs to current tenant + ).first() if not db_job: raise HTTPException(status_code=404, detail="Job not found") @@ -110,9 +135,16 @@ async def update_job(job_id: int, job_update: JobUpdate, db: Session = Depends(S return db_job @router.delete("/{job_id}") -async def delete_job(job_id: int, db: Session = Depends(SessionLocal)): +async def delete_job(job_id: int, db: Session = Depends(SessionLocal), request: Request = None): """Delete a job posting (soft delete by setting is_active to False)""" - db_job = db.query(JobPosting).filter(JobPosting.id == job_id).first() + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + + db_job = db.query(JobPosting).filter( + JobPosting.id == job_id, + JobPosting.tenant_id == tenant_id # Ensure job belongs to current tenant + ).first() if not db_job: raise HTTPException(status_code=404, detail="Job not found") diff --git a/qwen/python/merchants_of_hope/api/v1/users.py b/qwen/python/merchants_of_hope/api/v1/users.py index d65738e..5b06883 100644 --- a/qwen/python/merchants_of_hope/api/v1/users.py +++ b/qwen/python/merchants_of_hope/api/v1/users.py @@ -1,14 +1,14 @@ """ Users API routes """ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Request from typing import List from pydantic import BaseModel import hashlib from sqlalchemy.orm import Session from ..database import SessionLocal -from ..models import User, UserRole +from ..models import User, UserRole, Tenant from ..config.settings import settings router = APIRouter() @@ -42,22 +42,42 @@ def hash_password(password: str) -> str: return hashlib.sha256(password.encode()).hexdigest() @router.get("/", response_model=List[UserResponse]) -async def get_users(skip: int = 0, limit: int = 100, db: Session = Depends(SessionLocal)): - """Get all users""" - users = db.query(User).offset(skip).limit(limit).all() +async def get_users(skip: int = 0, limit: int = 100, db: Session = Depends(SessionLocal), request: Request = None): + """Get all users for the current tenant""" + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + + query = db.query(User) + if settings.MULTI_TENANT_ENABLED: + query = query.filter(User.tenant_id == tenant_id) + + users = query.offset(skip).limit(limit).all() return users @router.get("/{user_id}", response_model=UserResponse) -async def get_user(user_id: int, db: Session = Depends(SessionLocal)): +async def get_user(user_id: int, db: Session = Depends(SessionLocal), request: Request = None): """Get a specific user""" - user = db.query(User).filter(User.id == user_id).first() + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + + query = db.query(User).filter(User.id == user_id) + if settings.MULTI_TENANT_ENABLED: + query = query.filter(User.tenant_id == tenant_id) + + user = query.first() if not user: raise HTTPException(status_code=404, detail="User not found") return user @router.post("/", response_model=UserResponse) -async def create_user(user: UserCreate, db: Session = Depends(SessionLocal)): +async def create_user(user: UserCreate, db: Session = Depends(SessionLocal), request: Request = None): """Create a new user""" + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + # Check if user already exists existing_user = db.query(User).filter( (User.email == user.email) | (User.username == user.username) @@ -73,7 +93,7 @@ async def create_user(user: UserCreate, db: Session = Depends(SessionLocal)): username=user.username, hashed_password=hashed_pwd, role=user.role.value, - tenant_id=1 # Default tenant, in real app would come from context + tenant_id=tenant_id # Use the current tenant ) db.add(db_user) db.commit() @@ -81,9 +101,16 @@ async def create_user(user: UserCreate, db: Session = Depends(SessionLocal)): return db_user @router.put("/{user_id}", response_model=UserResponse) -async def update_user(user_id: int, user_update: UserUpdate, db: Session = Depends(SessionLocal)): +async def update_user(user_id: int, user_update: UserUpdate, db: Session = Depends(SessionLocal), request: Request = None): """Update a user""" - db_user = db.query(User).filter(User.id == user_id).first() + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + + db_user = db.query(User).filter( + User.id == user_id, + User.tenant_id == tenant_id # Ensure user belongs to current tenant + ).first() if not db_user: raise HTTPException(status_code=404, detail="User not found") @@ -100,9 +127,16 @@ async def update_user(user_id: int, user_update: UserUpdate, db: Session = Depen return db_user @router.delete("/{user_id}") -async def delete_user(user_id: int, db: Session = Depends(SessionLocal)): +async def delete_user(user_id: int, db: Session = Depends(SessionLocal), request: Request = None): """Delete a user""" - db_user = db.query(User).filter(User.id == user_id).first() + tenant_id = getattr(request.state, 'tenant_id', None) + if not tenant_id and settings.MULTI_TENANT_ENABLED: + raise HTTPException(status_code=400, detail="Tenant ID is required") + + db_user = db.query(User).filter( + User.id == user_id, + User.tenant_id == tenant_id # Ensure user belongs to current tenant + ).first() if not db_user: raise HTTPException(status_code=404, detail="User not found") diff --git a/qwen/python/merchants_of_hope/middleware/tenant.py b/qwen/python/merchants_of_hope/middleware/tenant.py index 31e3d37..4d0753f 100644 --- a/qwen/python/merchants_of_hope/middleware/tenant.py +++ b/qwen/python/merchants_of_hope/middleware/tenant.py @@ -1,31 +1,44 @@ """ Tenant middleware for handling multi-tenant requests """ -import uuid from typing import Optional from fastapi import Request, HTTPException, status from starlette.middleware.base import BaseHTTPMiddleware +from sqlalchemy.orm import Session + from .config.settings import settings from .models import Tenant +from .database import SessionLocal + class TenantMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): - # Get tenant ID from header or subdomain + # Get tenant ID from header tenant_id = request.headers.get(settings.TENANT_ID_HEADER) + # If not in header, try to extract from subdomain if not tenant_id: - # Try to extract tenant from subdomain host = request.headers.get("host", "") tenant_id = self.extract_tenant_from_host(host) - if not tenant_id and settings.MULTI_TENANT_ENABLED: + # Look up tenant in database + tenant = None + if tenant_id: + db: Session = SessionLocal() + try: + tenant = db.query(Tenant).filter(Tenant.subdomain == tenant_id).first() + finally: + db.close() + + if settings.MULTI_TENANT_ENABLED and not tenant: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Tenant ID is required" + detail="Valid tenant ID is required" ) # Attach tenant info to request - request.state.tenant_id = tenant_id + request.state.tenant = tenant + request.state.tenant_id = tenant.id if tenant else None response = await call_next(request) return response @@ -34,7 +47,9 @@ class TenantMiddleware(BaseHTTPMiddleware): """ Extract tenant from host (subdomain.tenant.com) """ - # For now, return a default tenant or None - # In a real implementation, you would parse the subdomain - # and look up the corresponding tenant in the database - return "default" \ No newline at end of file + import re + # Match subdomain from host (e.g., "tenant1.example.com" -> "tenant1") + match = re.match(r'^([^.]+)\.', host) + if match: + return match.group(1) + return None \ No newline at end of file