diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8bae8dc --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +APP_NAME=ScoutIQ +APP_ENV=local +APP_DEBUG=true +APP_URL=http://localhost:8000 +APP_KEY=base64:3uFzGf9o8+D+U0mC4/3K1y4m81Qj7G6qTzS= + +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=scoutDb +DB_USERNAME=scoutUser +DB_PASSWORD=your_database_password + +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD=null + +MAIL_MAILER=smtp +MAIL_HOST=127.0.0.1 +MAIL_PORT=1025 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="info@scoutiq.intaleqapp.com" +MAIL_FROM_NAME="ScoutIQ" + +RESEND_API_KEY=your_resend_api_key_here +AWS_ACCESS_KEY_ID=your_aws_access_key_here +AWS_SECRET_ACCESS_KEY=your_aws_secret_here +AWS_DEFAULT_REGION=us-east-1 + +GEMINI_API_KEY=your_gemini_api_key_here +JWT_SECRET=your_jwt_secret_key_here diff --git a/app/Controllers/Admin/DashboardController.php b/app/Controllers/Admin/DashboardController.php new file mode 100644 index 0000000..8dc37a4 --- /dev/null +++ b/app/Controllers/Admin/DashboardController.php @@ -0,0 +1,22 @@ +routeParam('_authenticated_user'); + return $this->render('admin/dashboard', [ + 'user' => $user, + 'title' => 'Dashboard', + ], 'admin'); + } +} diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..297d339 --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,115 @@ +authService = $authService; + $this->logger = $logger; + } + + /** + * Render the login page. + */ + public function showLogin(Request $request, Response $response): string + { + if ($this->session->get('user_id')) { + $response->redirect('/admin/dashboard'); + } + return $this->render('auth/login', [], 'auth'); + } + + /** + * Handle login requests. + */ + public function login(Request $request, Response $response): void + { + $email = $request->post('email', ''); + $password = $request->post('password', ''); + + try { + $user = $this->authService->login($email, $password); + + $this->session->set('user_id', $user['id']); + $this->session->set('user_name', $user['name']); + $this->session->set('user_email', $user['email']); + + // Security log + $this->logger->log($user['id'], 'user_login', 'User logged in successfully via Web.'); + + $this->session->setFlash('success', 'Welcome back, ' . $user['name'] . '!'); + $response->redirect('/admin/dashboard'); + } catch (Throwable $e) { + $this->session->setFlash('error', $e->getMessage()); + $response->redirect('/login'); + } + } + + /** + * Render registration page. + */ + public function showRegister(Request $request, Response $response): string + { + if ($this->session->get('user_id')) { + $response->redirect('/admin/dashboard'); + } + return $this->render('auth/register', [], 'auth'); + } + + /** + * Handle registration requests. + */ + public function register(Request $request, Response $response): void + { + $name = $request->post('name', ''); + $email = $request->post('email', ''); + $password = $request->post('password', ''); + + try { + if (empty($name) || empty($email) || empty($password)) { + throw new \Exception("All fields are required."); + } + + $user = $this->authService->register($name, $email, $password); + + $this->session->set('user_id', $user['id']); + $this->session->set('user_name', $user['name']); + $this->session->set('user_email', $user['email']); + + // Security log + $this->logger->log($user['id'], 'user_register', 'User registered and logged in.'); + + $this->session->setFlash('success', 'Registration successful! Welcome to ScoutIQ.'); + $response->redirect('/admin/dashboard'); + } catch (Throwable $e) { + $this->session->setFlash('error', $e->getMessage()); + $response->redirect('/register'); + } + } + + /** + * Destroy user sessions and logout. + */ + public function logout(Request $request, Response $response): void + { + $userId = $this->session->get('user_id'); + if ($userId) { + $this->logger->log($userId, 'user_logout', 'User logged out.'); + } + + $this->session->destroy(); + $response->redirect('/login'); + } +} diff --git a/app/Controllers/Controller.php b/app/Controllers/Controller.php new file mode 100644 index 0000000..7368f9f --- /dev/null +++ b/app/Controllers/Controller.php @@ -0,0 +1,53 @@ +session = App::$app->session; + } + + /** + * Render a view within a layout. + */ + protected function render(string $view, array $data = [], string $layout = 'app'): string + { + $viewFile = __DIR__ . "/../../resources/views/{$view}.php"; + if (!file_exists($viewFile)) { + throw new \Exception("View template {$view} not found."); + } + + // Extract variables to local scope + extract($data); + + // Capture inner view content + ob_start(); + include $viewFile; + $content = ob_get_clean(); + + // Capture layout content wrapping the inner view + $layoutFile = __DIR__ . "/../../resources/views/layouts/{$layout}.php"; + if (!file_exists($layoutFile)) { + return $content; + } + + ob_start(); + include $layoutFile; + return ob_get_clean(); + } + + /** + * Escape string values for rendering safely. + */ + protected function escape(mixed $data): string + { + return htmlspecialchars((string)$data, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/app/Controllers/HomeController.php b/app/Controllers/HomeController.php new file mode 100644 index 0000000..a462658 --- /dev/null +++ b/app/Controllers/HomeController.php @@ -0,0 +1,21 @@ +session->get('user_id')) { + $response->redirect('/admin/dashboard'); + return; + } + $response->redirect('/login'); + } +} diff --git a/app/Core/App.php b/app/Core/App.php new file mode 100644 index 0000000..ba8bbed --- /dev/null +++ b/app/Core/App.php @@ -0,0 +1,123 @@ +container = $container; + $this->request = new Request(); + $this->response = new Response(); + $this->session = new Session(); + $this->router = new Router(); + } + + /** + * Boot and run the application. + */ + public function run(): void + { + try { + // Match request to route + $routeInfo = $this->router->resolve($this->request); + $callback = $routeInfo['callback']; + $middlewares = $routeInfo['middleware']; + $params = $routeInfo['params']; + + // Inject matched route parameters into Request + $this->request->setRouteParams($params); + + // Run Middleware Chain + $this->executeMiddlewareChain($middlewares, function() use ($callback) { + // Execute Route action + if (is_callable($callback)) { + $response = $callback($this->request, $this->response); + } else { + [$controllerClass, $method] = $callback; + $controller = $this->container->get($controllerClass); + $response = $controller->$method($this->request, $this->response); + } + + // Auto-output string responses as HTML + if (is_string($response)) { + $this->response->html($response); + } + }); + + } catch (Throwable $e) { + $this->handleException($e); + } + } + + /** + * Executes the array of middlewares sequentially before firing destination action. + */ + private function executeMiddlewareChain(array $middlewares, callable $destination): void + { + $runner = function (int $index) use ($middlewares, $destination, &$runner) { + if ($index >= count($middlewares)) { + $destination(); + return; + } + + $middlewareClass = $middlewares[$index]; + /** @var MiddlewareInterface $middleware */ + $middleware = $this->container->get($middlewareClass); + + $middleware->handle($this->request, $this->response, function() use ($runner, $index) { + $runner($index + 1); + }); + }; + + $runner(0); + } + + /** + * Global exception handler. + */ + private function handleException(Throwable $e): void + { + $code = $e->getCode(); + if ($code < 100 || $code > 599) { + $code = 500; + } + + // Return API error JSON for API routes or JSON requests + if ($this->request->isJson() || str_starts_with($this->request->getPath(), '/api')) { + $this->response->json([ + 'success' => false, + 'error' => $e->getMessage(), + 'code' => $code + ], $code); + return; + } + + // Render error templates for Web requests + $viewPath = __DIR__ . "/../../resources/views/errors/{$code}.php"; + if (!file_exists($viewPath)) { + $viewPath = __DIR__ . '/../../resources/views/errors/500.php'; + } + + if (file_exists($viewPath)) { + ob_start(); + $message = $e->getMessage(); + include $viewPath; + $content = ob_get_clean(); + $this->response->html($content, $code); + } else { + $this->response->html("
{$e->getMessage()}
", $code); + } + } +} diff --git a/app/Core/Container.php b/app/Core/Container.php new file mode 100644 index 0000000..f005e37 --- /dev/null +++ b/app/Core/Container.php @@ -0,0 +1,116 @@ +bindings[$id] = [ + 'concrete' => $concrete, + 'singleton' => $singleton + ]; + } + + /** + * Bind a singleton dependency. + */ + public function singleton(string $id, callable|string $concrete): void + { + $this->bind($id, $concrete, true); + } + + /** + * Get a dependency from the container. + */ + public function get(string $id): mixed + { + if (isset($this->instances[$id])) { + return $this->instances[$id]; + } + + if (!$this->has($id)) { + return $this->resolve($id); + } + + $binding = $this->bindings[$id]; + $concrete = $binding['concrete']; + + $object = $this->resolve($concrete); + + if ($binding['singleton']) { + $this->instances[$id] = $object; + } + + return $object; + } + + /** + * Check if a dependency is bound. + */ + public function has(string $id): bool + { + return isset($this->bindings[$id]) || isset($this->instances[$id]) || class_exists($id); + } + + /** + * Resolve a concrete class or binding. + */ + private function resolve(mixed $concrete): mixed + { + if ($concrete instanceof \Closure || is_callable($concrete)) { + return $concrete($this); + } + + if (is_string($concrete)) { + if (!class_exists($concrete)) { + return $concrete; + } + + $reflector = new \ReflectionClass($concrete); + + if (!$reflector->isInstantiable()) { + throw new Exception("Class {$concrete} is not instantiable."); + } + + $constructor = $reflector->getConstructor(); + + if (null === $constructor) { + return new $concrete(); + } + + $parameters = $constructor->getParameters(); + $dependencies = []; + + foreach ($parameters as $parameter) { + $type = $parameter->getType(); + + if (!$type) { + if ($parameter->isDefaultValueAvailable()) { + $dependencies[] = $parameter->getDefaultValue(); + } else { + throw new Exception("Cannot resolve parameter {$parameter->getName()} in class {$concrete}"); + } + } elseif ($type instanceof \ReflectionUnionType) { + throw new Exception("Union types in constructor injection not supported for class {$concrete}"); + } else { + $typeName = $type->getName(); + $dependencies[] = $this->get($typeName); + } + } + + return $reflector->newInstanceArgs($dependencies); + } + + return $concrete; + } +} diff --git a/app/Core/Request.php b/app/Core/Request.php new file mode 100644 index 0000000..294a34c --- /dev/null +++ b/app/Core/Request.php @@ -0,0 +1,215 @@ +get = $_GET; + $this->post = $_POST; + $this->server = $_SERVER; + $this->files = $_FILES; + $this->cookies = $_COOKIE; + } + + /** + * Get request path without query string. + */ + public function getPath(): string + { + $uri = $this->server['REQUEST_URI'] ?? '/'; + $position = strpos($uri, '?'); + if ($position === false) { + return $uri; + } + return substr($uri, 0, $position); + } + + /** + * Get request HTTP method (e.g., GET, POST, PUT, DELETE). + */ + public function getMethod(): string + { + return strtoupper($this->server['REQUEST_METHOD'] ?? 'GET'); + } + + public function isGet(): bool + { + return $this->getMethod() === 'GET'; + } + + public function isPost(): bool + { + return $this->getMethod() === 'POST'; + } + + public function isPut(): bool + { + return $this->getMethod() === 'PUT'; + } + + public function isDelete(): bool + { + return $this->getMethod() === 'DELETE'; + } + + /** + * Set route parameters (extracted by Router). + */ + public function setRouteParams(array $params): void + { + $this->routeParams = $params; + } + + /** + * Get route parameters or specific key. + */ + public function getRouteParams(): array + { + return $this->routeParams; + } + + public function routeParam(string $key, mixed $default = null): mixed + { + return $this->routeParams[$key] ?? $default; + } + + /** + * Fetch GET parameter. + */ + public function get(string $key, mixed $default = null): mixed + { + return $this->get[$key] ?? $default; + } + + /** + * Fetch POST parameter. + */ + public function post(string $key, mixed $default = null): mixed + { + return $this->post[$key] ?? $default; + } + + /** + * Fetch all request parameters parsed. + */ + public function getBody(): array + { + if ($this->isJson()) { + return $this->getJsonBody(); + } + + $body = []; + if ($this->isGet()) { + foreach ($this->get as $key => $value) { + $body[$key] = filter_input(INPUT_GET, $key, FILTER_DEFAULT); + } + } + if ($this->isPost()) { + foreach ($this->post as $key => $value) { + $body[$key] = filter_input(INPUT_POST, $key, FILTER_DEFAULT); + } + } + return $body; + } + + /** + * Check if request is JSON. + */ + public function isJson(): bool + { + $contentType = $this->server['CONTENT_TYPE'] ?? $this->server['HTTP_CONTENT_TYPE'] ?? ''; + return str_contains($contentType, 'application/json'); + } + + /** + * Parse raw JSON request payload. + */ + public function getJsonBody(): array + { + if ($this->json === null) { + $rawInput = file_get_contents('php://input'); + $this->json = json_decode($rawInput, true) ?? []; + } + return $this->json; + } + + /** + * Get a specific input value (works for GET, POST, and JSON). + */ + public function input(string $key, mixed $default = null): mixed + { + $body = $this->getBody(); + return $body[$key] ?? $default; + } + + /** + * Get all request headers. + */ + public function getHeaders(): array + { + $headers = []; + foreach ($this->server as $key => $value) { + if (str_starts_with($key, 'HTTP_')) { + $name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5))))); + $headers[$name] = $value; + } elseif ($key === 'CONTENT_TYPE') { + $headers['Content-Type'] = $value; + } elseif ($key === 'CONTENT_LENGTH') { + $headers['Content-Length'] = $value; + } + } + return $headers; + } + + /** + * Get specific request header. + */ + public function getHeader(string $name, ?string $default = null): ?string + { + $headers = $this->getHeaders(); + // Match case-insensitively + foreach ($headers as $key => $val) { + if (strtolower($key) === strtolower($name)) { + return $val; + } + } + return $default; + } + + /** + * Get Client IP address. + */ + public function getIp(): string + { + return $this->server['HTTP_CLIENT_IP'] + ?? $this->server['HTTP_X_FORWARDED_FOR'] + ?? $this->server['REMOTE_ADDR'] + ?? '127.0.0.1'; + } + + /** + * Get User Agent. + */ + public function getUserAgent(): string + { + return $this->server['HTTP_USER_AGENT'] ?? 'Unknown'; + } + + /** + * Get uploaded file array. + */ + public function file(string $key): ?array + { + return $this->files[$key] ?? null; + } +} diff --git a/app/Core/Response.php b/app/Core/Response.php new file mode 100644 index 0000000..efd94c6 --- /dev/null +++ b/app/Core/Response.php @@ -0,0 +1,89 @@ +statusCode = $code; + return $this; + } + + /** + * Get the HTTP status code. + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * Add a header. + */ + public function header(string $name, string $value): self + { + $this->headers[$name] = $value; + return $this; + } + + /** + * Set redirect path. + */ + public function redirect(string $url): void + { + header("Location: " . $url); + exit; + } + + /** + * Output JSON data. + */ + public function json(mixed $data, int $code = 200): void + { + $this->setStatusCode($code); + $this->header('Content-Type', 'application/json; charset=utf-8'); + + $this->sendHeaders(); + http_response_code($this->statusCode); + + echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + exit; + } + + /** + * Output raw HTML or string content. + */ + public function html(string $content, int $code = 200): void + { + $this->setStatusCode($code); + if (!isset($this->headers['Content-Type'])) { + $this->header('Content-Type', 'text/html; charset=utf-8'); + } + + $this->sendHeaders(); + http_response_code($this->statusCode); + + echo $content; + } + + /** + * Send all buffered headers. + */ + public function sendHeaders(): void + { + if (headers_sent()) { + return; + } + + foreach ($this->headers as $name => $value) { + header("$name: $value"); + } + } +} diff --git a/app/Core/Router.php b/app/Core/Router.php new file mode 100644 index 0000000..c35fd08 --- /dev/null +++ b/app/Core/Router.php @@ -0,0 +1,113 @@ +groupPrefix, '/'); + $prefix = $prefix === '/' ? '' : $prefix; + + $trimmedPath = '/' . trim($path, '/'); + $trimmedPath = $trimmedPath === '/' ? '' : $trimmedPath; + + $fullPath = $prefix . $trimmedPath; + $fullPath = $fullPath === '' ? '/' : $fullPath; + + $this->routes[strtoupper($method)][] = [ + 'path' => $fullPath, + 'callback' => $callback, + 'middleware' => array_merge($this->groupMiddleware, $middleware) + ]; + } + + public function get(string $path, array|callable $callback, array $middleware = []): void + { + $this->addRoute('GET', $path, $callback, $middleware); + } + + public function post(string $path, array|callable $callback, array $middleware = []): void + { + $this->addRoute('POST', $path, $callback, $middleware); + } + + public function put(string $path, array|callable $callback, array $middleware = []): void + { + $this->addRoute('PUT', $path, $callback, $middleware); + } + + public function delete(string $path, array|callable $callback, array $middleware = []): void + { + $this->addRoute('DELETE', $path, $callback, $middleware); + } + + /** + * Group routes under attributes like prefix and middleware. + */ + public function group(array $attributes, callable $callback): void + { + $previousPrefix = $this->groupPrefix; + $previousMiddleware = $this->groupMiddleware; + + if (isset($attributes['prefix'])) { + $this->groupPrefix = $previousPrefix . '/' . trim($attributes['prefix'], '/'); + } + + if (isset($attributes['middleware'])) { + $this->groupMiddleware = array_merge($previousMiddleware, (array)$attributes['middleware']); + } + + $callback($this); + + $this->groupPrefix = $previousPrefix; + $this->groupMiddleware = $previousMiddleware; + } + + /** + * Match path against configured routes. + */ + public function resolve(Request $request): array + { + $method = $request->getMethod(); + $path = '/' . trim($request->getPath(), '/'); + + $routes = $this->routes[$method] ?? []; + + foreach ($routes as $route) { + $pattern = $this->compilePattern($route['path']); + if (preg_match($pattern, $path, $matches)) { + // Filter out non-string keys from named capture groups + $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); + return [ + 'callback' => $route['callback'], + 'middleware' => $route['middleware'], + 'params' => $params + ]; + } + } + + throw new Exception("Route not found", 404); + } + + /** + * Convert route notation {param} into regex capture groups. + */ + private function compilePattern(string $path): string + { + $cleanPath = '/' . trim($path, '/'); + $cleanPath = $cleanPath === '/' ? '' : $cleanPath; + + $pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<$1>[^/]+)', $cleanPath); + return '#^' . ($pattern === '' ? '/' : $pattern) . '$#'; + } +} diff --git a/app/Core/Session.php b/app/Core/Session.php new file mode 100644 index 0000000..3f7e27e --- /dev/null +++ b/app/Core/Session.php @@ -0,0 +1,148 @@ + true, + 'cookie_secure' => false, // Set to true if HTTPS is enforced (e.g. on production) + 'cookie_samesite' => 'Lax', + ]); + } + + // Mark existing flash messages to be deleted next request + $this->ageFlashMessages(); + } + + /** + * Set a session value. + */ + public function set(string $key, mixed $value): void + { + $_SESSION[$key] = $value; + } + + /** + * Get a session value. + */ + public function get(string $key, mixed $default = null): mixed + { + return $_SESSION[$key] ?? $default; + } + + /** + * Remove a session value. + */ + public function remove(string $key): void + { + unset($_SESSION[$key]); + } + + /** + * Check if a session key exists. + */ + public function has(string $key): bool + { + return isset($_SESSION[$key]); + } + + /** + * Set a flash message (available only for the next request). + */ + public function setFlash(string $key, string $message): void + { + $_SESSION[self::FLASH_KEY][$key] = [ + 'value' => $message, + 'remove' => false + ]; + } + + /** + * Get a flash message. + */ + public function getFlash(string $key, ?string $default = null): ?string + { + return $_SESSION[self::FLASH_KEY][$key]['value'] ?? $default; + } + + /** + * Get all flash messages. + */ + public function getFlashes(): array + { + $flashes = []; + foreach ($_SESSION[self::FLASH_KEY] ?? [] as $key => $flash) { + $flashes[$key] = $flash['value']; + } + return $flashes; + } + + /** + * Age flash messages at start of request. + */ + private function ageFlashMessages(): void + { + $flashMessages = $_SESSION[self::FLASH_KEY] ?? []; + foreach ($flashMessages as $key => &$flash) { + if ($flash['remove']) { + unset($flashMessages[$key]); + } else { + $flash['remove'] = true; + } + } + $_SESSION[self::FLASH_KEY] = $flashMessages; + } + + /** + * Generate or fetch CSRF token. + */ + public function getCsrfToken(): string + { + $token = $this->get('csrf_token'); + if (!$token) { + $token = bin2hex(random_bytes(32)); + $this->set('csrf_token', $token); + } + return $token; + } + + /** + * Validate CSRF token. + */ + public function validateCsrfToken(?string $token): bool + { + if (!$token) { + return false; + } + $storedToken = $this->get('csrf_token'); + return hash_equals($storedToken, $token); + } + + /** + * Destroy the session. + */ + public function destroy(): void + { + $_SESSION = []; + if (ini_get("session.use_cookies")) { + $params = session_get_cookie_params(); + setcookie( + session_name(), + '', + time() - 42000, + $params["path"], + $params["domain"], + $params["secure"], + $params["httponly"] + ); + } + session_destroy(); + } +} diff --git a/app/Middleware/Authenticate.php b/app/Middleware/Authenticate.php new file mode 100644 index 0000000..6b57d75 --- /dev/null +++ b/app/Middleware/Authenticate.php @@ -0,0 +1,67 @@ +session = $session; + $this->authService = $authService; + } + + /** + * Authenticate session or JWT bearer token. + */ + public function handle(Request $request, Response $response, callable $next): void + { + $path = $request->getPath(); + + // 1. API Route Authentication (JWT verification) + if (str_starts_with($path, '/api')) { + $authHeader = $request->getHeader('Authorization'); + if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) { + throw new Exception("Unauthorized. Bearer token missing.", 401); + } + + $token = substr($authHeader, 7); + $user = $this->authService->verifyJwt($token); + if (!$user) { + throw new Exception("Unauthorized. Invalid or expired token.", 401); + } + + // Inject the authenticated user into route parameters for controller access + $request->setRouteParams(array_merge($request->getRouteParams(), ['_authenticated_user' => $user])); + $next(); + return; + } + + // 2. Web Route Authentication (Session verification) + $userId = $this->session->get('user_id'); + if (!$userId) { + $this->session->setFlash('error', 'Please login to access this page.'); + $response->redirect('/login'); + return; + } + + $user = $this->authService->getUserById($userId); + if (!$user) { + $this->session->destroy(); + $response->redirect('/login'); + return; + } + + // Inject the authenticated user + $request->setRouteParams(array_merge($request->getRouteParams(), ['_authenticated_user' => $user])); + $next(); + } +} diff --git a/app/Middleware/CsrfProtection.php b/app/Middleware/CsrfProtection.php new file mode 100644 index 0000000..6848ace --- /dev/null +++ b/app/Middleware/CsrfProtection.php @@ -0,0 +1,39 @@ +session = $session; + } + + /** + * Handle CSRF token validation. + */ + public function handle(Request $request, Response $response, callable $next): void + { + // Skip validation for read-only requests + if (in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) { + $next(); + return; + } + + // Retrieve token from request parameters or custom header + $token = $request->input('_csrf') ?? $request->getHeader('X-CSRF-Token'); + + if (!$this->session->validateCsrfToken($token)) { + throw new Exception("CSRF token validation failed. Request untrusted.", 403); + } + + $next(); + } +} diff --git a/app/Middleware/MiddlewareInterface.php b/app/Middleware/MiddlewareInterface.php new file mode 100644 index 0000000..b60cdaf --- /dev/null +++ b/app/Middleware/MiddlewareInterface.php @@ -0,0 +1,18 @@ +redis = new RedisClient([ + 'scheme' => 'tcp', + 'host' => $config['host'], + 'port' => $config['port'], + 'password' => $config['password'], + 'timeout' => 0.5, // 500ms connection timeout to fail fast + ]); + $this->redis->connect(); + } catch (Throwable $e) { + // Degrade gracefully if Redis server is down + $this->redis = null; + } + } + } + + /** + * Handle rate limiting logic. + */ + public function handle(Request $request, Response $response, callable $next): void + { + if ($this->redis === null) { + // Redis unavailable, skip throttle check to avoid service outage + $next(); + return; + } + + $ip = $request->getIp(); + $path = $request->getPath(); + $key = "rate_limit:" . md5($ip . ":" . $path); + + try { + $current = $this->redis->get($key); + + if ($current !== null && (int)$current >= $this->limit) { + $ttl = $this->redis->ttl($key); + $response->header('Retry-After', (string)max(1, $ttl)); + throw new Exception("Too Many Requests. Rate limit exceeded.", 429); + } + + if ($current === null) { + // First request in the time frame window + $this->redis->setex($key, $this->window, 1); + $current = 0; + } else { + $this->redis->incr($key); + } + + // Set rate limit headers + $remaining = $this->limit - ((int)$current + 1); + $response->header('X-RateLimit-Limit', (string)$this->limit); + $response->header('X-RateLimit-Remaining', (string)max(0, $remaining)); + + } catch (Throwable $e) { + if ($e->getCode() === 429) { + throw $e; + } + // Logging or catching connection dropping mid-request + } + + $next(); + } +} diff --git a/app/Middleware/SecurityHeaders.php b/app/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..4e7291d --- /dev/null +++ b/app/Middleware/SecurityHeaders.php @@ -0,0 +1,46 @@ +header('X-Frame-Options', 'SAMEORIGIN'); + + // Prevent MIME type sniffing + $response->header('X-Content-Type-Options', 'nosniff'); + + // Referrer policy + $response->header('Referrer-Policy', 'no-referrer-when-downgrade'); + + // Cross-Site Scripting protection + $response->header('X-XSS-Protection', '1; mode=block'); + + // HTTP Strict Transport Security (HSTS) - force HTTPS + $response->header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); + + // Content Security Policy (CSP) + // Allow scripts from self, google fonts, CDN js, styles from self/fonts + $csp = "default-src 'self'; " . + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; " . + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " . + "font-src 'self' https://fonts.gstatic.com; " . + "img-src 'self' data: https:; " . + "connect-src 'self'; " . + "frame-ancestors 'none'; " . + "base-uri 'self'; " . + "form-action 'self';"; + + $response->header('Content-Security-Policy', $csp); + + $next(); + } +} diff --git a/app/Models/Model.php b/app/Models/Model.php new file mode 100644 index 0000000..b07cc00 --- /dev/null +++ b/app/Models/Model.php @@ -0,0 +1,152 @@ +attributes = $attributes; + if (self::$db === null && isset(App::$app)) { + $connection = App::$app->container->get(Connection::class); + self::$db = $connection->getPdo(); + } + } + + /** + * Get active database connection. + */ + public static function getDb(): PDO + { + if (self::$db === null) { + $connection = App::$app->container->get(Connection::class); + self::$db = $connection->getPdo(); + } + return self::$db; + } + + /** + * Magic getter for attributes. + */ + public function __get(string $name) + { + return $this->attributes[$name] ?? null; + } + + /** + * Magic setter for attributes. + */ + public function __set(string $name, $value): void + { + $this->attributes[$name] = $value; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * Find a record by ID. + */ + public static function find(int|string $id): ?static + { + $instance = new static(); + $db = self::getDb(); + + $sql = "SELECT * FROM {$instance->table} WHERE {$instance->primaryKey} = :id AND deleted_at IS NULL LIMIT 1"; + $stmt = $db->prepare($sql); + $stmt->execute(['id' => $id]); + + $data = $stmt->fetch(); + if (!$data) { + return null; + } + + return new static($data); + } + + /** + * Fetch all active records. + */ + public static function all(): array + { + $instance = new static(); + $db = self::getDb(); + + $sql = "SELECT * FROM {$instance->table} WHERE deleted_at IS NULL"; + $stmt = $db->query($sql); + + $results = []; + while ($row = $stmt->fetch()) { + $results[] = new static($row); + } + + return $results; + } + + /** + * Save the active record (INSERT or UPDATE). + */ + public function save(): bool + { + $db = self::getDb(); + $id = $this->attributes[$this->primaryKey] ?? null; + + if ($id) { + // Update flow + $fields = []; + $params = []; + foreach ($this->attributes as $key => $value) { + if ($key === $this->primaryKey || $key === 'created_at' || $key === 'updated_at') { + continue; + } + $fields[] = "`{$key}` = :{$key}"; + $params[$key] = $value; + } + $params[$this->primaryKey] = $id; + + $sql = "UPDATE `{$this->table}` SET " . implode(', ', $fields) . " WHERE `{$this->primaryKey}` = :{$this->primaryKey}"; + $stmt = $db->prepare($sql); + return $stmt->execute($params); + } else { + // Insert flow + $keys = array_keys($this->attributes); + $placeholders = array_map(fn($key) => ":{$key}", $keys); + + $sql = "INSERT INTO `{$this->table}` (" . implode(', ', array_map(fn($k) => "`{$k}`", $keys)) . ") VALUES (" . implode(', ', $placeholders) . ")"; + $stmt = $db->prepare($sql); + $success = $stmt->execute($this->attributes); + + if ($success) { + $this->attributes[$this->primaryKey] = (int)$db->lastInsertId(); + } + return $success; + } + } + + /** + * Soft delete active record. + */ + public function delete(): bool + { + $db = self::getDb(); + $id = $this->attributes[$this->primaryKey] ?? null; + if (!$id) { + return false; + } + + $sql = "UPDATE `{$this->table}` SET deleted_at = NOW() WHERE `{$this->primaryKey}` = :id"; + $stmt = $db->prepare($sql); + return $stmt->execute(['id' => $id]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..7bd5435 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,8 @@ +pdo = $connection->getPdo(); + $aiConfig = require __DIR__ . '/../../../config/ai.php'; + $this->jwtConfig = $aiConfig['jwt']; + } + + /** + * Register a new user. + */ + public function register(string $name, string $email, string $password): array + { + // Check for duplicates + $stmt = $this->pdo->prepare("SELECT id FROM users WHERE email = ?"); + $stmt->execute([$email]); + if ($stmt->fetch()) { + throw new Exception("Email already registered."); + } + + $passwordHash = password_hash($password, PASSWORD_BCRYPT); + + $this->pdo->beginTransaction(); + try { + $stmt = $this->pdo->prepare("INSERT INTO users (name, email, password_hash, status) VALUES (?, ?, ?, 'active')"); + $stmt->execute([$name, $email, $passwordHash]); + $userId = (int)$this->pdo->lastInsertId(); + + // Count users to assign role: first user gets Admin, others get Member + $stmt = $this->pdo->query("SELECT COUNT(*) FROM users"); + $count = (int)$stmt->fetchColumn(); + + $roleCode = $count === 1 ? 'admin' : 'member'; + $stmt = $this->pdo->prepare("SELECT id FROM roles WHERE code = ?"); + $stmt->execute([$roleCode]); + $roleId = $stmt->fetchColumn(); + + if ($roleId) { + $stmt = $this->pdo->prepare("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)"); + $stmt->execute([$userId, $roleId]); + } + + $this->pdo->commit(); + + return [ + 'id' => $userId, + 'name' => $name, + 'email' => $email, + 'status' => 'active' + ]; + } catch (Throwable $e) { + $this->pdo->rollBack(); + throw new Exception("Registration failed: " . $e->getMessage()); + } + } + + /** + * Authenticate a user by email and password. + */ + public function login(string $email, string $password): array + { + $stmt = $this->pdo->prepare("SELECT id, name, email, password_hash, status FROM users WHERE email = ? AND deleted_at IS NULL"); + $stmt->execute([$email]); + $user = $stmt->fetch(); + + if (!$user || !password_verify($password, $user['password_hash'])) { + throw new Exception("Invalid email or password."); + } + + if ($user['status'] !== 'active') { + throw new Exception("User account is inactive."); + } + + unset($user['password_hash']); + return $user; + } + + /** + * Get active user by ID. + */ + public function getUserById(int $id): ?array + { + $stmt = $this->pdo->prepare("SELECT id, name, email, status FROM users WHERE id = ? AND deleted_at IS NULL"); + $stmt->execute([$id]); + $user = $stmt->fetch(); + return $user ?: null; + } + + /** + * Generate JWT for APIs. + */ + public function generateJwt(array $user): string + { + $issuedAt = time(); + $expire = $issuedAt + $this->jwtConfig['expires_in']; + + $payload = [ + 'iss' => $_ENV['APP_URL'] ?? 'https://scoutiq.intaleqapp.com', + 'aud' => $_ENV['APP_URL'] ?? 'https://scoutiq.intaleqapp.com', + 'iat' => $issuedAt, + 'exp' => $expire, + 'sub' => $user['id'], + 'user' => [ + 'id' => $user['id'], + 'name' => $user['name'], + 'email' => $user['email'] + ] + ]; + + return JWT::encode($payload, $this->jwtConfig['secret'], $this->jwtConfig['algorithm']); + } + + /** + * Decode and verify JWT. + */ + public function verifyJwt(string $token): ?array + { + try { + $decoded = JWT::decode($token, new Key($this->jwtConfig['secret'], $this->jwtConfig['algorithm'])); + $payload = (array)$decoded; + + if (isset($payload['user'])) { + return (array)$payload['user']; + } + + return $this->getUserById((int)$payload['sub']); + } catch (Throwable $e) { + return null; + } + } +} diff --git a/app/Services/Auth/RBAC.php b/app/Services/Auth/RBAC.php new file mode 100644 index 0000000..fd34057 --- /dev/null +++ b/app/Services/Auth/RBAC.php @@ -0,0 +1,81 @@ +pdo = $connection->getPdo(); + } + + /** + * Check if a user has a specific permission. + */ + public function hasPermission(int $userId, string $permissionCode): bool + { + $permissions = $this->getUserPermissions($userId); + return in_array($permissionCode, $permissions); + } + + /** + * Get all unique permissions code associated with the user's roles. + */ + public function getUserPermissions(int $userId): array + { + if (isset($this->permissionCache[$userId])) { + return $this->permissionCache[$userId]; + } + + $sql = "SELECT DISTINCT p.code + FROM permissions p + JOIN role_permissions rp ON p.id = rp.permission_id + JOIN user_roles ur ON rp.role_id = ur.role_id + WHERE ur.user_id = :user_id"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute(['user_id' => $userId]); + + $permissions = $stmt->fetchAll(PDO::FETCH_COLUMN); + $permissions = $permissions ?: []; + + $this->permissionCache[$userId] = $permissions; + + return $permissions; + } + + /** + * Get user roles. + */ + public function getUserRoles(int $userId): array + { + $sql = "SELECT r.code + FROM roles r + JOIN user_roles ur ON r.id = ur.role_id + WHERE ur.user_id = :user_id"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute(['user_id' => $userId]); + + $roles = $stmt->fetchAll(PDO::FETCH_COLUMN); + return $roles ?: []; + } + + /** + * Assign a role to a user. + */ + public function assignRoleToUser(int $userId, int $roleId): void + { + $stmt = $this->pdo->prepare("INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)"); + $stmt->execute([$userId, $roleId]); + + // Invalidate in-memory permission cache for this user + unset($this->permissionCache[$userId]); + } +} diff --git a/app/Services/Database/ActivityLogger.php b/app/Services/Database/ActivityLogger.php new file mode 100644 index 0000000..a31169d --- /dev/null +++ b/app/Services/Database/ActivityLogger.php @@ -0,0 +1,33 @@ +pdo = $connection->getPdo(); + $this->request = $request; + } + + /** + * Log user or system activity. + */ + public function log(?int $userId, string $action, ?string $description = null): void + { + $ip = $this->request->getIp(); + $ua = $this->request->getUserAgent(); + + $sql = "INSERT INTO activity_logs (user_id, action, description, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?)"; + + $stmt = $this->pdo->prepare($sql); + $stmt->execute([$userId, $action, $description, $ip, $ua]); + } +} diff --git a/app/Services/Database/Connection.php b/app/Services/Database/Connection.php new file mode 100644 index 0000000..43aac8d --- /dev/null +++ b/app/Services/Database/Connection.php @@ -0,0 +1,42 @@ +pdo = new PDO($dsn, $config['username'], $config['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 Exception("Database connection failed: " . $e->getMessage()); + } + } + + /** + * Get active PDO instance. + */ + public function getPdo(): PDO + { + return $this->pdo; + } +} diff --git a/app/Services/Database/MigrationRunner.php b/app/Services/Database/MigrationRunner.php new file mode 100644 index 0000000..3107738 --- /dev/null +++ b/app/Services/Database/MigrationRunner.php @@ -0,0 +1,105 @@ +pdo = $connection->getPdo(); + $this->migrationsDir = __DIR__ . '/../../../database/migrations'; + } + + /** + * Run all pending migrations. + */ + public function migrate(): void + { + $this->createMigrationsTable(); + + $executed = $this->getExecutedMigrations(); + + if (!is_dir($this->migrationsDir)) { + mkdir($this->migrationsDir, 0755, true); + } + + $files = glob($this->migrationsDir . '/*.sql'); + if ($files === false) { + $files = []; + } + sort($files); + + $count = 0; + foreach ($files as $file) { + $name = basename($file); + if (!in_array($name, $executed)) { + echo "Running migration: {$name}...\n"; + $this->executeSqlFile($file); + $this->logMigration($name); + echo "Successfully ran: {$name}\n"; + $count++; + } + } + + if ($count === 0) { + echo "Nothing to migrate. Database is up to date!\n"; + } + } + + /** + * Create the tracking migrations table if it does not exist. + */ + private function createMigrationsTable(): void + { + $sql = "CREATE TABLE IF NOT EXISTS migrations ( + id INT AUTO_INCREMENT PRIMARY KEY, + migration VARCHAR(255) NOT NULL, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"; + $this->pdo->exec($sql); + } + + /** + * Get list of already executed migrations. + */ + private function getExecutedMigrations(): array + { + $stmt = $this->pdo->query("SELECT migration FROM migrations"); + $results = $stmt->fetchAll(PDO::FETCH_COLUMN); + return $results ?: []; + } + + /** + * Execute SQL file. + */ + private function executeSqlFile(string $filePath): void + { + $sql = file_get_contents($filePath); + if (!$sql) { + return; + } + + try { + // Run SQL commands + $this->pdo->exec($sql); + } catch (\PDOException $e) { + echo "Error running migration from file: " . basename($filePath) . "\n"; + echo "Details: " . $e->getMessage() . "\n"; + throw $e; + } + } + + /** + * Log executed migration. + */ + private function logMigration(string $name): void + { + $stmt = $this->pdo->prepare("INSERT INTO migrations (migration) VALUES (?)"); + $stmt->execute([$name]); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..466f40c --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,41 @@ +load(); +} + +use App\Core\Container; +use App\Core\App; +use App\Services\Database\Connection; +use App\Services\Auth\AuthService; +use App\Services\Auth\RBAC; +use App\Services\Database\ActivityLogger; +use App\Middleware\CsrfProtection; +use App\Middleware\RateLimit; +use App\Middleware\SecurityHeaders; +use App\Middleware\Authenticate; + +$container = new Container(); + +// Database Core configurations +$container->singleton(Connection::class, Connection::class); + +// Services bindings +$container->singleton(AuthService::class, AuthService::class); +$container->singleton(RBAC::class, RBAC::class); +$container->singleton(ActivityLogger::class, ActivityLogger::class); + +// Middleware bindings +$container->bind(CsrfProtection::class, CsrfProtection::class); +$container->bind(RateLimit::class, RateLimit::class); +$container->bind(SecurityHeaders::class, SecurityHeaders::class); +$container->bind(Authenticate::class, Authenticate::class); + +// Instantiate App +$app = new App($container); + +return $app; diff --git a/cli.php b/cli.php new file mode 100644 index 0000000..4e9ec1b --- /dev/null +++ b/cli.php @@ -0,0 +1,62 @@ +load(); +} + +use App\Core\Container; +use App\Services\Database\Connection; +use App\Services\Database\MigrationRunner; +use Database\Seeds\DatabaseSeeder; + +$container = new Container(); + +// Singletons configurations +$container->singleton(Connection::class, Connection::class); +$container->singleton(MigrationRunner::class, MigrationRunner::class); +$container->singleton(DatabaseSeeder::class, DatabaseSeeder::class); + +$args = $argv; +$command = $args[1] ?? 'help'; + +switch ($command) { + case 'migrate': + echo "=== Running Migrations ===\n"; + try { + $runner = $container->get(MigrationRunner::class); + $runner->migrate(); + } catch (\Throwable $e) { + echo "Migration failed: " . $e->getMessage() . "\n"; + exit(1); + } + break; + + case 'seed': + echo "=== Running Database Seeder ===\n"; + try { + $seeder = $container->get(DatabaseSeeder::class); + $seeder->seed(); + } catch (\Throwable $e) { + echo "Seeding failed: " . $e->getMessage() . "\n"; + exit(1); + } + break; + + case 'help': + default: + echo "ScoutIQ CLI Utility\n"; + echo "Usage: php cli.php [command]\n\n"; + echo "Commands:\n"; + echo " migrate Run SQL database migrations\n"; + echo " seed Seed the database with default data\n"; + echo " help Show this help menu\n"; + break; +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a6d99a3 --- /dev/null +++ b/composer.json @@ -0,0 +1,22 @@ +{ + "name": "scoutiq/platform", + "description": "ScoutIQ - AI-powered Investor Intelligence Platform", + "type": "project", + "license": "proprietary", + "require": { + "php": ">=8.4", + "vlucas/phpdotenv": "^5.6", + "predis/predis": "^2.2", + "firebase/php-jwt": "^6.10", + "psr/container": "^2.0" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + } + }, + "config": { + "sort-packages": true, + "optimize-autoloader": true + } +} diff --git a/config/ai.php b/config/ai.php new file mode 100644 index 0000000..6660c24 --- /dev/null +++ b/config/ai.php @@ -0,0 +1,13 @@ + [ + 'api_key' => ($_ENV['GEMINI_API_KEY'] === 'null' || !$_ENV['GEMINI_API_KEY']) ? null : $_ENV['GEMINI_API_KEY'], + 'model' => 'gemini-flash-lite-latest', + ], + 'jwt' => [ + 'secret' => $_ENV['JWT_SECRET'] ?? 'base64:3uFzGf9o8+D+U0mC4/3K1y4m81Qj7G6qTzS=', + 'algorithm' => 'HS256', + 'expires_in' => 86400 * 30, // 30 days + ], +]; diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..17705bb --- /dev/null +++ b/config/app.php @@ -0,0 +1,11 @@ + $_ENV['APP_NAME'] ?? 'ScoutIQ', + 'env' => $_ENV['APP_ENV'] ?? 'production', + 'debug' => filter_var($_ENV['APP_DEBUG'] ?? false, FILTER_VALIDATE_BOOLEAN), + 'url' => $_ENV['APP_URL'] ?? 'https://scoutiq.intaleqapp.com', + 'key' => $_ENV['APP_KEY'] ?? 'base64:3uFzGf9o8+D+U0mC4/3K1y4m81Qj7G6qTzS=', + 'timezone' => 'Asia/Riyadh', + 'log_path' => __DIR__ . '/../storage/logs/app.log', +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..aaa7921 --- /dev/null +++ b/config/database.php @@ -0,0 +1,11 @@ + $_ENV['DB_HOST'] ?? '127.0.0.1', + 'port' => $_ENV['DB_PORT'] ?? '3306', + 'database' => $_ENV['DB_DATABASE'] ?? 'scoutDb', + 'username' => $_ENV['DB_USERNAME'] ?? 'scoutUser', + 'password' => $_ENV['DB_PASSWORD'] ?? '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..fac8076 --- /dev/null +++ b/config/mail.php @@ -0,0 +1,27 @@ + $_ENV['MAIL_MAILER'] ?? 'smtp', + + 'mailers' => [ + 'smtp' => [ + 'host' => $_ENV['MAIL_HOST'] ?? '127.0.0.1', + 'port' => (int)($_ENV['MAIL_PORT'] ?? 2525), + 'username' => ($_ENV['MAIL_USERNAME'] === 'null' || !$_ENV['MAIL_USERNAME']) ? null : $_ENV['MAIL_USERNAME'], + 'password' => ($_ENV['MAIL_PASSWORD'] === 'null' || !$_ENV['MAIL_PASSWORD']) ? null : $_ENV['MAIL_PASSWORD'], + ], + 'resend' => [ + 'key' => ($_ENV['RESEND_API_KEY'] === 'null' || !$_ENV['RESEND_API_KEY']) ? null : $_ENV['RESEND_API_KEY'], + ], + 'ses' => [ + 'key' => ($_ENV['AWS_ACCESS_KEY_ID'] === 'null' || !$_ENV['AWS_ACCESS_KEY_ID']) ? null : $_ENV['AWS_ACCESS_KEY_ID'], + 'secret' => ($_ENV['AWS_SECRET_ACCESS_KEY'] === 'null' || !$_ENV['AWS_SECRET_ACCESS_KEY']) ? null : $_ENV['AWS_SECRET_ACCESS_KEY'], + 'region' => $_ENV['AWS_DEFAULT_REGION'] ?? 'us-east-1', + ], + ], + + 'from' => [ + 'address' => $_ENV['MAIL_FROM_ADDRESS'] ?? 'info@scoutiq.intaleqapp.com', + 'name' => $_ENV['MAIL_FROM_NAME'] ?? 'ScoutIQ', + ], +]; diff --git a/config/redis.php b/config/redis.php new file mode 100644 index 0000000..22351a7 --- /dev/null +++ b/config/redis.php @@ -0,0 +1,7 @@ + $_ENV['REDIS_HOST'] ?? '127.0.0.1', + 'port' => (int)($_ENV['REDIS_PORT'] ?? 6379), + 'password' => ($_ENV['REDIS_PASSWORD'] === 'null' || !$_ENV['REDIS_PASSWORD']) ? null : $_ENV['REDIS_PASSWORD'], +]; diff --git a/database/migrations/001_create_roles_and_permissions.sql b/database/migrations/001_create_roles_and_permissions.sql new file mode 100644 index 0000000..b256b56 --- /dev/null +++ b/database/migrations/001_create_roles_and_permissions.sql @@ -0,0 +1,25 @@ +CREATE TABLE roles ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + code VARCHAR(100) NOT NULL UNIQUE, + description TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE permissions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + code VARCHAR(100) NOT NULL UNIQUE, + description TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE role_permissions ( + role_id BIGINT UNSIGNED NOT NULL, + permission_id BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (role_id, permission_id), + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/migrations/002_create_users_table.sql b/database/migrations/002_create_users_table.sql new file mode 100644 index 0000000..07dec92 --- /dev/null +++ b/database/migrations/002_create_users_table.sql @@ -0,0 +1,20 @@ +CREATE TABLE users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_users_email (email), + INDEX idx_users_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE user_roles ( + user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (user_id, role_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/migrations/003_create_organizations_and_subtypes.sql b/database/migrations/003_create_organizations_and_subtypes.sql new file mode 100644 index 0000000..b258c5e --- /dev/null +++ b/database/migrations/003_create_organizations_and_subtypes.sql @@ -0,0 +1,69 @@ +CREATE TABLE organizations ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + domain VARCHAR(255) NULL UNIQUE, + description TEXT NULL, + type VARCHAR(50) NOT NULL, -- 'vc', 'angel', 'accelerator', 'incubator', 'venture_studio', 'partner' + country VARCHAR(100) NULL, + city VARCHAR(100) NULL, + website_url VARCHAR(2048) NULL, + linkedin_url VARCHAR(2048) NULL, + logo_url VARCHAR(2048) NULL, + employee_count INT NULL, + funding_stage VARCHAR(100) NULL, + crm_status VARCHAR(50) NOT NULL DEFAULT 'New', -- 'New', 'Researching', 'Contacted', 'Follow Up', 'Meeting Scheduled', 'Interested', 'Rejected', 'Invested' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_orgs_type (type), + INDEX idx_orgs_crm_status (crm_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE investors ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id BIGINT UNSIGNED NOT NULL UNIQUE, + min_ticket_size DECIMAL(15,2) NULL, + max_ticket_size DECIMAL(15,2) NULL, + investment_stages JSON NULL, -- e.g. ['Pre-Seed', 'Seed'] + target_geographies JSON NULL, -- e.g. ['KSA', 'Egypt'] + active_portfolio_count INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE accelerators ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id BIGINT UNSIGNED NOT NULL UNIQUE, + cohort_size INT NULL, + program_duration_weeks INT NULL, + equity_taken_percent DECIMAL(5,2) NULL, + funding_provided DECIMAL(15,2) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE incubators ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id BIGINT UNSIGNED NOT NULL UNIQUE, + program_duration_weeks INT NULL, + facilities_offered JSON NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE venture_studios ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id BIGINT UNSIGNED NOT NULL UNIQUE, + focus_areas JSON NULL, + resources_provided JSON NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/migrations/004_create_contacts_and_crm.sql b/database/migrations/004_create_contacts_and_crm.sql new file mode 100644 index 0000000..7a8ffda --- /dev/null +++ b/database/migrations/004_create_contacts_and_crm.sql @@ -0,0 +1,32 @@ +CREATE TABLE contacts ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id BIGINT UNSIGNED NULL, + first_name VARCHAR(150) NOT NULL, + last_name VARCHAR(150) NOT NULL, + email VARCHAR(255) NULL, + phone VARCHAR(50) NULL, + title VARCHAR(150) NULL, + linkedin_url VARCHAR(2048) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL, + INDEX idx_contacts_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE crm_activities ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id BIGINT UNSIGNED NOT NULL, + contact_id BIGINT UNSIGNED NULL, + type VARCHAR(50) NOT NULL, -- 'meeting', 'email', 'note', 'follow_up' + details TEXT NOT NULL, + scheduled_at TIMESTAMP NULL, + completed_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE SET NULL, + INDEX idx_crm_act_type (type), + INDEX idx_crm_act_org (organization_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/migrations/005_create_opportunities_and_tags.sql b/database/migrations/005_create_opportunities_and_tags.sql new file mode 100644 index 0000000..b2d115a --- /dev/null +++ b/database/migrations/005_create_opportunities_and_tags.sql @@ -0,0 +1,82 @@ +CREATE TABLE opportunities ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + type VARCHAR(50) NOT NULL, -- 'grant', 'competition', 'demo_day', 'event', 'partnership', 'investment' + organization_id BIGINT UNSIGNED NULL, + url VARCHAR(2048) NULL, + deadline TIMESTAMP NULL, + amount DECIMAL(15, 2) NULL, + location VARCHAR(255) NULL, + status VARCHAR(50) NOT NULL DEFAULT 'active', + score INT NOT NULL DEFAULT 0, + raw_data JSON NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL, + INDEX idx_opps_type (type), + INDEX idx_opps_status (status), + INDEX idx_opps_score (score) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE tags ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + slug VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE opportunity_tags ( + opportunity_id BIGINT UNSIGNED NOT NULL, + tag_id BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (opportunity_id, tag_id), + FOREIGN KEY (opportunity_id) REFERENCES opportunities(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE applications ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + opportunity_id BIGINT UNSIGNED NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'draft', -- 'draft', 'submitted', 'under_review', 'accepted', 'rejected' + submission_date TIMESTAMP NULL, + notes TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + FOREIGN KEY (opportunity_id) REFERENCES opportunities(id) ON DELETE CASCADE, + INDEX idx_applications_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE partnerships ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + partner_organization_id BIGINT UNSIGNED NOT NULL, + target_organization_id BIGINT UNSIGNED NULL, + terms TEXT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'open', -- 'open', 'signed', 'cancelled' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + FOREIGN KEY (partner_organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (target_organization_id) REFERENCES organizations(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE startup_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id BIGINT UNSIGNED NULL, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + start_date TIMESTAMP NULL, + end_date TIMESTAMP NULL, + location_type VARCHAR(50) NOT NULL, -- 'online', 'in_person', 'hybrid' + location_address VARCHAR(500) NULL, + event_url VARCHAR(2048) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL, + INDEX idx_events_start (start_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/migrations/006_create_sources_and_tracking.sql b/database/migrations/006_create_sources_and_tracking.sql new file mode 100644 index 0000000..04be1eb --- /dev/null +++ b/database/migrations/006_create_sources_and_tracking.sql @@ -0,0 +1,19 @@ +CREATE TABLE sources ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + url VARCHAR(2048) NOT NULL, + type VARCHAR(50) NOT NULL, -- 'rss', 'api', 'website' + status VARCHAR(50) NOT NULL DEFAULT 'active', + last_crawled_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + INDEX idx_sources_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE source_categories ( + source_id BIGINT UNSIGNED NOT NULL, + category VARCHAR(100) NOT NULL, + PRIMARY KEY (source_id, category), + FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/migrations/007_create_reports_and_notifications.sql b/database/migrations/007_create_reports_and_notifications.sql new file mode 100644 index 0000000..39757a9 --- /dev/null +++ b/database/migrations/007_create_reports_and_notifications.sql @@ -0,0 +1,56 @@ +CREATE TABLE ai_reports ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content JSON NOT NULL, + generation_date DATE NOT NULL, + report_type VARCHAR(50) NOT NULL DEFAULT 'daily', -- 'daily', 'weekly', 'custom' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_ai_reports_date (generation_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE email_reports ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ai_report_id BIGINT UNSIGNED NOT NULL, + recipient_email VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'pending', -- 'pending', 'sent', 'failed' + sent_at TIMESTAMP NULL, + error_message TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (ai_report_id) REFERENCES ai_reports(id) ON DELETE CASCADE, + INDEX idx_email_rep_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE activity_logs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NULL, + action VARCHAR(255) NOT NULL, + description TEXT NULL, + ip_address VARCHAR(45) NULL, + user_agent VARCHAR(500) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE notifications ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + read_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_notifications_unread (user_id, read_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE settings ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `key` VARCHAR(100) NOT NULL UNIQUE, + value TEXT NULL, + description TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_settings_key (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/seeds/DatabaseSeeder.php b/database/seeds/DatabaseSeeder.php new file mode 100644 index 0000000..53f347b --- /dev/null +++ b/database/seeds/DatabaseSeeder.php @@ -0,0 +1,154 @@ +pdo = $connection->getPdo(); + } + + /** + * Seed all defaults. + */ + public function seed(): void + { + echo "Seeding roles and permissions...\n"; + $this->seedRolesAndPermissions(); + + echo "Seeding default settings...\n"; + $this->seedSettings(); + + echo "Seeding sample crawler sources...\n"; + $this->seedSources(); + + echo "Seeding default administrator...\n"; + $this->seedDefaultAdmin(); + + echo "Seeding completed successfully!\n"; + } + + private function seedRolesAndPermissions(): void + { + // Insert Roles + $roles = [ + ['name' => 'Administrator', 'code' => 'admin', 'description' => 'Full administrative access to the platform.'], + ['name' => 'Member', 'code' => 'member', 'description' => 'Access to CRM, Opportunities and Reports.'], + ]; + + foreach ($roles as $role) { + $stmt = $this->pdo->prepare("INSERT IGNORE INTO roles (name, code, description) VALUES (?, ?, ?)"); + $stmt->execute([$role['name'], $role['code'], $role['description']]); + } + + // Insert Permissions + $permissions = [ + ['name' => 'View Dashboard', 'code' => 'view_dashboard', 'description' => 'Access the admin dashboard metrics.'], + ['name' => 'Manage CRM', 'code' => 'manage_crm', 'description' => 'Create/update contacts and log interactions.'], + ['name' => 'View Opportunities', 'code' => 'view_opportunities', 'description' => 'Browse VCs, grants and accelerators.'], + ['name' => 'Run Collectors', 'code' => 'run_collectors', 'description' => 'Manually trigger crawler collections.'], + ['name' => 'Generate Reports', 'code' => 'generate_reports', 'description' => 'Trigger AI daily summaries.'], + ['name' => 'Manage Settings', 'code' => 'manage_settings', 'description' => 'Update application parameters.'], + ['name' => 'Manage Users', 'code' => 'manage_users', 'description' => 'Manage roles and accounts of users.'], + ]; + + foreach ($permissions as $permission) { + $stmt = $this->pdo->prepare("INSERT IGNORE INTO permissions (name, code, description) VALUES (?, ?, ?)"); + $stmt->execute([$permission['name'], $permission['code'], $permission['description']]); + } + + // Fetch Role IDs and Permission IDs + $roleIds = $this->pdo->query("SELECT id, code FROM roles")->fetchAll(PDO::FETCH_KEY_PAIR); + $permissionIds = $this->pdo->query("SELECT id, code FROM permissions")->fetchAll(PDO::FETCH_KEY_PAIR); + + // Map Permissions to Admin (All permissions) + foreach ($permissionIds as $pId => $pCode) { + $stmt = $this->pdo->prepare("INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"); + $stmt->execute([$roleIds['admin'], $pId]); + } + + // Map Permissions to Member (Subset of permissions) + $memberPermissions = ['view_dashboard', 'manage_crm', 'view_opportunities', 'generate_reports']; + foreach ($permissionIds as $pId => $pCode) { + if (in_array($pCode, $memberPermissions)) { + $stmt = $this->pdo->prepare("INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"); + $stmt->execute([$roleIds['member'], $pId]); + } + } + } + + private function seedSettings(): void + { + $settings = [ + ['key' => 'system_email', 'value' => 'info@scoutiq.intaleqapp.com', 'description' => 'Primary sender email address.'], + ['key' => 'crawler_enabled', 'value' => 'true', 'description' => 'Global toggle for data collection crawlers.'], + ['key' => 'crawler_interval_hours', 'value' => '24', 'description' => 'Delay between crawler runs.'], + ]; + + foreach ($settings as $setting) { + $stmt = $this->pdo->prepare("INSERT IGNORE INTO settings (`key`, value, description) VALUES (?, ?, ?)"); + $stmt->execute([$setting['key'], $setting['value'], $setting['description']]); + } + } + + private function seedSources(): void + { + $sources = [ + ['name' => 'TechCrunch Mobility Feed', 'url' => 'https://techcrunch.com/category/transportation/feed/', 'type' => 'rss'], + ['name' => 'VentureBeat AI Feed', 'url' => 'https://venturebeat.com/category/ai/feed/', 'type' => 'rss'], + ['name' => 'YC Directory API', 'url' => 'https://api.ycombinator.com/v1/companies', 'type' => 'api'], + ]; + + foreach ($sources as $source) { + $stmt = $this->pdo->prepare("SELECT id FROM sources WHERE url = ?"); + $stmt->execute([$source['url']]); + if (!$stmt->fetch()) { + $stmt = $this->pdo->prepare("INSERT INTO sources (name, url, type, status) VALUES (?, ?, ?, 'active')"); + $stmt->execute([$source['name'], $source['url'], $source['type']]); + $sourceId = (int)$this->pdo->lastInsertId(); + + $category = str_contains($source['name'], 'Mobility') ? 'mobility' : 'ai'; + $stmt = $this->pdo->prepare("INSERT INTO source_categories (source_id, category) VALUES (?, ?)"); + $stmt->execute([$sourceId, $category]); + } + } + } + + private function seedDefaultAdmin(): void + { + $email = 'admin@scoutiq.com'; + $stmt = $this->pdo->prepare("SELECT id FROM users WHERE email = ?"); + $stmt->execute([$email]); + if (!$stmt->fetch()) { + $passwordHash = password_hash('AdminScout123!', PASSWORD_BCRYPT); + + $this->pdo->beginTransaction(); + try { + $stmt = $this->pdo->prepare("INSERT INTO users (name, email, password_hash, status) VALUES (?, ?, ?, 'active')"); + $stmt->execute(['ScoutIQ Admin', $email, $passwordHash]); + $userId = (int)$this->pdo->lastInsertId(); + + $stmt = $this->pdo->prepare("SELECT id FROM roles WHERE code = 'admin'"); + $stmt->execute(); + $roleId = $stmt->fetchColumn(); + + if ($roleId) { + $stmt = $this->pdo->prepare("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)"); + $stmt->execute([$userId, $roleId]); + } + + $this->pdo->commit(); + echo "Admin User Created: {$email} / AdminScout123!\n"; + } catch (\Exception $e) { + $this->pdo->rollBack(); + echo "Failed to create default admin: " . $e->getMessage() . "\n"; + } + } + } +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..2edc321 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# ScoutIQ Deployment Script + +echo "=== ScoutIQ Deploying Script ===" + +# Get current date and time +datetime=$(date "+%Y-%m-%d %H:%M:%S") + +# Git workflow +echo "Adding changes to Git..." +git add . + +echo "Committing changes..." +git commit -m "Deploy on $datetime" + +echo "Pushing changes to origin main..." +git push origin main + +echo "Done! You can now run git pull on the server." diff --git a/public/assets/css/admin.css b/public/assets/css/admin.css new file mode 100644 index 0000000..eed9d1a --- /dev/null +++ b/public/assets/css/admin.css @@ -0,0 +1,187 @@ +.admin-container { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 280px; + background: hsla(222, 47%, 10%, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-right: 1px solid var(--border-color); + padding: 40px 24px; + display: flex; + flex-direction: column; + gap: 40px; +} + +.brand { + font-size: 1.7rem; + font-weight: 800; + background: var(--primary-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + display: flex; + align-items: center; + gap: 12px; +} + +.nav-menu { + list-style: none; + display: flex; + flex-direction: column; + gap: 10px; +} + +.nav-item a { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 20px; + border-radius: var(--radius-md); + color: var(--text-muted); + font-weight: 500; + font-size: 0.95rem; + transition: var(--transition); +} + +.nav-item a:hover, .nav-item.active a { + color: var(--text-main); + background: hsla(217, 32%, 18%, 0.6); + border: 1px solid var(--border-color); +} + +.main-content { + flex: 1; + display: flex; + flex-direction: column; +} + +.top-bar { + height: 80px; + background: hsla(222, 47%, 8%, 0.4); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 40px; +} + +.user-info { + display: flex; + align-items: center; + gap: 14px; +} + +.avatar { + width: 42px; + height: 42px; + border-radius: 50%; + background: var(--primary-gradient); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: #fff; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); + font-family: 'Outfit', sans-serif; +} + +.content-body { + flex: 1; + padding: 40px; + display: flex; + flex-direction: column; + gap: 40px; + overflow-y: auto; +} + +.dashboard-header { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dashboard-header h1 { + font-size: 2.4rem; + font-weight: 700; + background: linear-gradient(135deg, #fff 60%, hsl(215, 20%, 75%)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.dashboard-header p { + color: var(--text-muted); +} + +/* Metric Cards Grid */ +.metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 24px; +} + +.metric-card { + padding: 28px; + display: flex; + flex-direction: column; + gap: 14px; + position: relative; + overflow: hidden; +} + +.metric-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background: var(--primary-gradient); +} + +.metric-title { + font-size: 0.85rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; +} + +.metric-value { + font-size: 2.2rem; + font-weight: 700; + color: var(--text-main); + font-family: 'Outfit', sans-serif; +} + +.metric-footer { + font-size: 0.8rem; + color: var(--success); + display: flex; + align-items: center; + gap: 6px; +} + +/* Charts Grid */ +.charts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); + gap: 24px; +} + +.chart-card { + padding: 28px; + display: flex; + flex-direction: column; + gap: 20px; + min-height: 320px; +} + +.chart-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-main); +} diff --git a/public/assets/css/app.css b/public/assets/css/app.css new file mode 100644 index 0000000..28c6943 --- /dev/null +++ b/public/assets/css/app.css @@ -0,0 +1,166 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap'); + +:root { + --bg-dark: hsl(222, 47%, 7%); + --bg-card: hsla(222, 47%, 11%, 0.7); + --border-color: hsla(217, 32%, 22%, 0.5); + + --primary: hsl(263, 90%, 60%); + --primary-hover: hsl(263, 90%, 65%); + --primary-gradient: linear-gradient(135deg, hsl(263, 90%, 60%) 0%, hsl(220, 95%, 50%) 100%); + + --accent: hsl(180, 100%, 50%); + --accent-glow: hsla(180, 100%, 50%, 0.15); + + --text-main: hsl(210, 40%, 98%); + --text-muted: hsl(215, 20%, 70%); + + --success: hsl(142, 70%, 45%); + --error: hsl(0, 84%, 60%); + --warning: hsl(38, 92%, 50%); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 20px; + --shadow-glow: 0 0 25px -5px hsla(263, 90%, 60%, 0.3); + + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--bg-dark); + color: var(--text-main); + font-family: 'Inter', sans-serif; + min-height: 100vh; + display: flex; + flex-direction: column; + overflow-x: hidden; + background-image: + radial-gradient(at 10% 20%, hsla(263, 90%, 50%, 0.1) 0px, transparent 50%), + radial-gradient(at 90% 80%, hsla(220, 95%, 50%, 0.08) 0px, transparent 50%); +} + +h1, h2, h3, h4, .brand-title { + font-family: 'Outfit', sans-serif; +} + +a { + color: var(--accent); + text-decoration: none; + transition: var(--transition); +} + +a:hover { + filter: brightness(1.2); + text-shadow: 0 0 8px var(--accent-glow); +} + +/* Glassmorphism Panel */ +.glass-panel { + background: var(--bg-card); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 12px 24px; + font-size: 0.95rem; + font-weight: 600; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + transition: var(--transition); + font-family: 'Outfit', sans-serif; +} + +.btn-primary { + background: var(--primary-gradient); + color: #fff; + box-shadow: var(--shadow-glow); +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 0 30px -2px hsla(263, 90%, 60%, 0.5); +} + +.btn-secondary { + background: hsla(217, 32%, 17%, 0.6); + color: var(--text-main); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background: hsla(217, 32%, 22%, 0.8); + transform: translateY(-2px); +} + +/* Form Inputs */ +.form-group { + margin-bottom: 20px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-label { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.form-control { + background: hsla(217, 32%, 12%, 0.5); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px 16px; + color: var(--text-main); + font-size: 0.95rem; + outline: none; + transition: var(--transition); +} + +.form-control:focus { + border-color: var(--primary); + box-shadow: 0 0 15px -3px hsla(263, 90%, 60%, 0.25); + background: hsla(217, 32%, 15%, 0.7); +} + +/* Alert notifications */ +.alert { + padding: 14px 20px; + border-radius: var(--radius-md); + margin-bottom: 20px; + font-size: 0.9rem; + font-weight: 500; + display: flex; + align-items: center; + border: 1px solid transparent; +} + +.alert-success { + background: hsla(142, 70%, 45%, 0.15); + color: var(--success); + border-color: hsla(142, 70%, 45%, 0.3); +} + +.alert-error { + background: hsla(0, 84%, 60%, 0.15); + color: var(--error); + border-color: hsla(0, 84%, 60%, 0.3); +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..517d5cb --- /dev/null +++ b/public/index.php @@ -0,0 +1,44 @@ +router->group([ + 'middleware' => [SecurityHeaders::class] +], function($router) { + + // Public index redirection + $router->get('/', [HomeController::class, 'index']); + + // Auth routes throttled via Rate Limiter + $router->group([ + 'middleware' => [RateLimit::class] + ], function($r) { + $r->get('/login', [AuthController::class, 'showLogin']); + $r->post('/login', [AuthController::class, 'login']); + $r->get('/register', [AuthController::class, 'showRegister']); + $r->post('/register', [AuthController::class, 'register']); + }); + + // Protected Admin routes requiring Session Auth & CSRF tokens + $router->group([ + 'prefix' => '/admin', + 'middleware' => [Authenticate::class, CsrfProtection::class] + ], function($r) { + $r->get('/dashboard', [DashboardController::class, 'index']); + }); + + // Logout endpoint + $router->get('/logout', [AuthController::class, 'logout']); +}); + +$app->run(); diff --git a/resources/views/admin/dashboard.php b/resources/views/admin/dashboard.php new file mode 100644 index 0000000..91cbfef --- /dev/null +++ b/resources/views/admin/dashboard.php @@ -0,0 +1,143 @@ +Here is your daily intelligence summary for ScoutIQ.
+AI-powered Investor Intelligence
+Join ScoutIQ Investor Platform
+You do not have the required role permissions to access this directory or perform this action.
+ Return Home +The resource you are looking for might have been removed, had its name changed, or is temporarily unavailable.
+ Return Home += isset($message) ? $this->escape($message) : 'A critical exception has occurred on the application server.' ?>
+ Return Home +