Complete Phase 1: MVC, DB migrations, Auth, RBAC, Security, and Views
This commit is contained in:
31
.env.example
Normal file
31
.env.example
Normal file
@@ -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
|
||||
22
app/Controllers/Admin/DashboardController.php
Normal file
22
app/Controllers/Admin/DashboardController.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Controllers\Controller;
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display admin dashboard.
|
||||
*/
|
||||
public function index(Request $request, Response $response): string
|
||||
{
|
||||
$user = $request->routeParam('_authenticated_user');
|
||||
return $this->render('admin/dashboard', [
|
||||
'user' => $user,
|
||||
'title' => 'Dashboard',
|
||||
], 'admin');
|
||||
}
|
||||
}
|
||||
115
app/Controllers/AuthController.php
Normal file
115
app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Services\Auth\AuthService;
|
||||
use App\Services\Database\ActivityLogger;
|
||||
use Throwable;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
private AuthService $authService;
|
||||
private ActivityLogger $logger;
|
||||
|
||||
public function __construct(AuthService $authService, ActivityLogger $logger)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
53
app/Controllers/Controller.php
Normal file
53
app/Controllers/Controller.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\App;
|
||||
use App\Core\Session;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
protected Session $session;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
21
app/Controllers/HomeController.php
Normal file
21
app/Controllers/HomeController.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle root url routing.
|
||||
*/
|
||||
public function index(Request $request, Response $response): void
|
||||
{
|
||||
if ($this->session->get('user_id')) {
|
||||
$response->redirect('/admin/dashboard');
|
||||
return;
|
||||
}
|
||||
$response->redirect('/login');
|
||||
}
|
||||
}
|
||||
123
app/Core/App.php
Normal file
123
app/Core/App.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use Throwable;
|
||||
use App\Middleware\MiddlewareInterface;
|
||||
|
||||
class App
|
||||
{
|
||||
public static App $app;
|
||||
public Container $container;
|
||||
public Request $request;
|
||||
public Response $response;
|
||||
public Session $session;
|
||||
public Router $router;
|
||||
|
||||
public function __construct(Container $container)
|
||||
{
|
||||
self::$app = $this;
|
||||
$this->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("<h1>Error {$code}</h1><p>{$e->getMessage()}</p>", $code);
|
||||
}
|
||||
}
|
||||
}
|
||||
116
app/Core/Container.php
Normal file
116
app/Core/Container.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Exception;
|
||||
|
||||
class Container implements ContainerInterface
|
||||
{
|
||||
private array $bindings = [];
|
||||
private array $instances = [];
|
||||
|
||||
/**
|
||||
* Bind a dependency to the container.
|
||||
*/
|
||||
public function bind(string $id, callable|string $concrete, bool $singleton = false): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
215
app/Core/Request.php
Normal file
215
app/Core/Request.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Request
|
||||
{
|
||||
private array $get;
|
||||
private array $post;
|
||||
private array $server;
|
||||
private array $files;
|
||||
private array $cookies;
|
||||
private ?array $json = null;
|
||||
private array $routeParams = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
89
app/Core/Response.php
Normal file
89
app/Core/Response.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Response
|
||||
{
|
||||
private int $statusCode = 200;
|
||||
private array $headers = [];
|
||||
|
||||
/**
|
||||
* Set the HTTP status code.
|
||||
*/
|
||||
public function setStatusCode(int $code): self
|
||||
{
|
||||
$this->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");
|
||||
}
|
||||
}
|
||||
}
|
||||
113
app/Core/Router.php
Normal file
113
app/Core/Router.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use Exception;
|
||||
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
private array $groupMiddleware = [];
|
||||
private string $groupPrefix = '';
|
||||
|
||||
/**
|
||||
* Map a route.
|
||||
*/
|
||||
public function addRoute(string $method, string $path, array|callable $callback, array $middleware = []): void
|
||||
{
|
||||
$prefix = '/' . trim($this->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) . '$#';
|
||||
}
|
||||
}
|
||||
148
app/Core/Session.php
Normal file
148
app/Core/Session.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Session
|
||||
{
|
||||
private const FLASH_KEY = 'flash_messages';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// Set secure session parameters
|
||||
session_start([
|
||||
'cookie_httponly' => 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();
|
||||
}
|
||||
}
|
||||
67
app/Middleware/Authenticate.php
Normal file
67
app/Middleware/Authenticate.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Session;
|
||||
use App\Services\Auth\AuthService;
|
||||
use Exception;
|
||||
|
||||
class Authenticate implements MiddlewareInterface
|
||||
{
|
||||
private Session $session;
|
||||
private AuthService $authService;
|
||||
|
||||
public function __construct(Session $session, AuthService $authService)
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
39
app/Middleware/CsrfProtection.php
Normal file
39
app/Middleware/CsrfProtection.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Core\Session;
|
||||
use Exception;
|
||||
|
||||
class CsrfProtection implements MiddlewareInterface
|
||||
{
|
||||
private Session $session;
|
||||
|
||||
public function __construct(Session $session)
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
18
app/Middleware/MiddlewareInterface.php
Normal file
18
app/Middleware/MiddlewareInterface.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
|
||||
interface MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Response $response
|
||||
* @param callable $next The next middleware/action in the chain
|
||||
*/
|
||||
public function handle(Request $request, Response $response, callable $next): void;
|
||||
}
|
||||
83
app/Middleware/RateLimit.php
Normal file
83
app/Middleware/RateLimit.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use Predis\Client as RedisClient;
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
class RateLimit implements MiddlewareInterface
|
||||
{
|
||||
private ?RedisClient $redis = null;
|
||||
private int $limit = 100; // Allow 100 requests
|
||||
private int $window = 60; // Per 60 seconds
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$config = require __DIR__ . '/../../config/redis.php';
|
||||
if (!empty($config['host'])) {
|
||||
try {
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
46
app/Middleware/SecurityHeaders.php
Normal file
46
app/Middleware/SecurityHeaders.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
|
||||
class SecurityHeaders implements MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* Add security-hardening headers to the response.
|
||||
*/
|
||||
public function handle(Request $request, Response $response, callable $next): void
|
||||
{
|
||||
// Enforce frame options to avoid clickjacking
|
||||
$response->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();
|
||||
}
|
||||
}
|
||||
152
app/Models/Model.php
Normal file
152
app/Models/Model.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\App;
|
||||
use App\Services\Database\Connection;
|
||||
use PDO;
|
||||
|
||||
abstract class Model
|
||||
{
|
||||
protected static ?PDO $db = null;
|
||||
protected string $table;
|
||||
protected string $primaryKey = 'id';
|
||||
protected array $attributes = [];
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
8
app/Models/User.php
Normal file
8
app/Models/User.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class User extends Model
|
||||
{
|
||||
protected string $table = 'users';
|
||||
}
|
||||
146
app/Services/Auth/AuthService.php
Normal file
146
app/Services/Auth/AuthService.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Services\Database\Connection;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use PDO;
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
class AuthService
|
||||
{
|
||||
private PDO $pdo;
|
||||
private array $jwtConfig;
|
||||
|
||||
public function __construct(Connection $connection)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
81
app/Services/Auth/RBAC.php
Normal file
81
app/Services/Auth/RBAC.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Services\Database\Connection;
|
||||
use PDO;
|
||||
|
||||
class RBAC
|
||||
{
|
||||
private PDO $pdo;
|
||||
private array $permissionCache = [];
|
||||
|
||||
public function __construct(Connection $connection)
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
33
app/Services/Database/ActivityLogger.php
Normal file
33
app/Services/Database/ActivityLogger.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Database;
|
||||
|
||||
use App\Core\Request;
|
||||
use PDO;
|
||||
|
||||
class ActivityLogger
|
||||
{
|
||||
private PDO $pdo;
|
||||
private Request $request;
|
||||
|
||||
public function __construct(Connection $connection, Request $request)
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
42
app/Services/Database/Connection.php
Normal file
42
app/Services/Database/Connection.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Database;
|
||||
|
||||
use PDO;
|
||||
use Exception;
|
||||
|
||||
class Connection
|
||||
{
|
||||
private ?PDO $pdo = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$config = require __DIR__ . '/../../../config/database.php';
|
||||
|
||||
$dsn = sprintf(
|
||||
"mysql:host=%s;port=%s;dbname=%s;charset=%s",
|
||||
$config['host'],
|
||||
$config['port'],
|
||||
$config['database'],
|
||||
$config['charset']
|
||||
);
|
||||
|
||||
try {
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
105
app/Services/Database/MigrationRunner.php
Normal file
105
app/Services/Database/MigrationRunner.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Database;
|
||||
|
||||
use PDO;
|
||||
|
||||
class MigrationRunner
|
||||
{
|
||||
private PDO $pdo;
|
||||
private string $migrationsDir;
|
||||
|
||||
public function __construct(Connection $connection)
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
41
bootstrap/app.php
Normal file
41
bootstrap/app.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Environment configurations
|
||||
if (file_exists(__DIR__ . '/../.env')) {
|
||||
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
|
||||
$dotenv->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;
|
||||
62
cli.php
Normal file
62
cli.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
die("This script can only be run from the command line.");
|
||||
}
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Environment configurations
|
||||
if (file_exists(__DIR__ . '/.env')) {
|
||||
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
|
||||
$dotenv->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;
|
||||
}
|
||||
22
composer.json
Normal file
22
composer.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
13
config/ai.php
Normal file
13
config/ai.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'gemini' => [
|
||||
'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
|
||||
],
|
||||
];
|
||||
11
config/app.php
Normal file
11
config/app.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => $_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',
|
||||
];
|
||||
11
config/database.php
Normal file
11
config/database.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'host' => $_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',
|
||||
];
|
||||
27
config/mail.php
Normal file
27
config/mail.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'default' => $_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',
|
||||
],
|
||||
];
|
||||
7
config/redis.php
Normal file
7
config/redis.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'host' => $_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'],
|
||||
];
|
||||
25
database/migrations/001_create_roles_and_permissions.sql
Normal file
25
database/migrations/001_create_roles_and_permissions.sql
Normal file
@@ -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;
|
||||
20
database/migrations/002_create_users_table.sql
Normal file
20
database/migrations/002_create_users_table.sql
Normal file
@@ -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;
|
||||
@@ -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;
|
||||
32
database/migrations/004_create_contacts_and_crm.sql
Normal file
32
database/migrations/004_create_contacts_and_crm.sql
Normal file
@@ -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;
|
||||
82
database/migrations/005_create_opportunities_and_tags.sql
Normal file
82
database/migrations/005_create_opportunities_and_tags.sql
Normal file
@@ -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;
|
||||
19
database/migrations/006_create_sources_and_tracking.sql
Normal file
19
database/migrations/006_create_sources_and_tracking.sql
Normal file
@@ -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;
|
||||
56
database/migrations/007_create_reports_and_notifications.sql
Normal file
56
database/migrations/007_create_reports_and_notifications.sql
Normal file
@@ -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;
|
||||
154
database/seeds/DatabaseSeeder.php
Normal file
154
database/seeds/DatabaseSeeder.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeds;
|
||||
|
||||
use App\Services\Database\Connection;
|
||||
use PDO;
|
||||
|
||||
class DatabaseSeeder
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(Connection $connection)
|
||||
{
|
||||
$this->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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
deploy.sh
Executable file
20
deploy.sh
Executable file
@@ -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."
|
||||
187
public/assets/css/admin.css
Normal file
187
public/assets/css/admin.css
Normal file
@@ -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);
|
||||
}
|
||||
166
public/assets/css/app.css
Normal file
166
public/assets/css/app.css
Normal file
@@ -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);
|
||||
}
|
||||
44
public/index.php
Normal file
44
public/index.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
/** @var App\Core\App $app */
|
||||
$app = require_once __DIR__ . '/../bootstrap/app.php';
|
||||
|
||||
use App\Controllers\HomeController;
|
||||
use App\Controllers\AuthController;
|
||||
use App\Controllers\Admin\DashboardController;
|
||||
use App\Middleware\SecurityHeaders;
|
||||
use App\Middleware\RateLimit;
|
||||
use App\Middleware\CsrfProtection;
|
||||
use App\Middleware\Authenticate;
|
||||
|
||||
// Register Global Web Middlewares on Route groups
|
||||
$app->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();
|
||||
143
resources/views/admin/dashboard.php
Normal file
143
resources/views/admin/dashboard.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<div class="dashboard-header">
|
||||
<h1>Welcome, <?= $this->escape($user['name']) ?></h1>
|
||||
<p>Here is your daily intelligence summary for ScoutIQ.</p>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Row -->
|
||||
<div class="metrics-grid">
|
||||
<div class="glass-panel metric-card">
|
||||
<span class="metric-title">Total Investors</span>
|
||||
<span class="metric-value">142</span>
|
||||
<span class="metric-footer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
|
||||
<span>+12% vs last month</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel metric-card">
|
||||
<span class="metric-title">Total Accelerators</span>
|
||||
<span class="metric-value">38</span>
|
||||
<span class="metric-footer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
|
||||
<span>+5% vs last month</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel metric-card">
|
||||
<span class="metric-title">Open Opportunities</span>
|
||||
<span class="metric-value">29</span>
|
||||
<span class="metric-footer" style="color: var(--warning);">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
<span>4 closing this week</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel metric-card">
|
||||
<span class="metric-title">Contacts Added Today</span>
|
||||
<span class="metric-value">7</span>
|
||||
<span class="metric-footer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
|
||||
<span>+3 new organizations</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="charts-grid">
|
||||
<div class="glass-panel chart-card">
|
||||
<span class="chart-title">Opportunities by Category</span>
|
||||
<div style="flex: 1; position: relative;">
|
||||
<canvas id="categoryChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel chart-card">
|
||||
<span class="chart-title">Monthly Growth & Ingestion</span>
|
||||
<div style="flex: 1; position: relative;">
|
||||
<canvas id="growthChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Category Chart (Doughnut)
|
||||
const ctxCategory = document.getElementById('categoryChart').getContext('2d');
|
||||
new Chart(ctxCategory, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Mobility & Logistics', 'AI & Automation', 'SaaS', 'Fintech', 'Marketplaces'],
|
||||
datasets: [{
|
||||
data: [35, 25, 20, 12, 8],
|
||||
backgroundColor: [
|
||||
'hsl(263, 90%, 60%)',
|
||||
'hsl(220, 95%, 50%)',
|
||||
'hsl(180, 100%, 40%)',
|
||||
'hsl(142, 70%, 45%)',
|
||||
'hsl(38, 92%, 50%)'
|
||||
],
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: 'hsl(215, 20%, 75%)',
|
||||
font: { family: 'Inter', size: 12 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Growth Chart (Line)
|
||||
const ctxGrowth = document.getElementById('growthChart').getContext('2d');
|
||||
new Chart(ctxGrowth, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
|
||||
datasets: [{
|
||||
label: 'Ingested Opportunities',
|
||||
data: [12, 19, 32, 45, 68, 96],
|
||||
borderColor: 'hsl(180, 100%, 50%)',
|
||||
backgroundColor: 'rgba(0, 242, 254, 0.1)',
|
||||
borderWidth: 3,
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}, {
|
||||
label: 'Interactions Logged',
|
||||
data: [5, 12, 18, 25, 30, 48],
|
||||
borderColor: 'hsl(263, 90%, 60%)',
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 3,
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: 'hsl(215, 20%, 75%)',
|
||||
font: { family: 'Inter', size: 12 }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||
ticks: { color: 'hsl(215, 20%, 70%)' }
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||
ticks: { color: 'hsl(215, 20%, 70%)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
40
resources/views/auth/login.php
Normal file
40
resources/views/auth/login.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<div class="glass-panel" style="width: 100%; max-width: 440px; padding: 40px; display: flex; flex-direction: column; gap: 30px;">
|
||||
<div style="text-align: center; display: flex; flex-direction: column; gap: 10px;">
|
||||
<h1 style="font-size: 2.2rem; font-weight: 800; background: var(--primary-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">ScoutIQ</h1>
|
||||
<p style="color: var(--text-muted); font-size: 0.95rem;">AI-powered Investor Intelligence</p>
|
||||
</div>
|
||||
|
||||
<?php if ($flashError = $this->session->getFlash('error')): ?>
|
||||
<div class="alert alert-error">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 10px;"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
<span><?= $this->escape($flashError) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($flashSuccess = $this->session->getFlash('success')): ?>
|
||||
<div class="alert alert-success">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 10px;"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
|
||||
<span><?= $this->escape($flashSuccess) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form action="/login" method="POST" style="display: flex; flex-direction: column; gap: 20px;">
|
||||
<input type="hidden" name="_csrf" value="<?= $this->session->getCsrfToken() ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="email">Email Address</label>
|
||||
<input type="email" name="email" id="email" class="form-control" placeholder="name@domain.com" required autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input type="password" name="password" id="password" class="form-control" placeholder="••••••••" required autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 10px;">Sign In</button>
|
||||
</form>
|
||||
|
||||
<div style="text-align: center; font-size: 0.9rem; color: var(--text-muted);">
|
||||
Don't have an account? <a href="/register" style="font-weight: 600;">Sign Up</a>
|
||||
</div>
|
||||
</div>
|
||||
38
resources/views/auth/register.php
Normal file
38
resources/views/auth/register.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<div class="glass-panel" style="width: 100%; max-width: 440px; padding: 40px; display: flex; flex-direction: column; gap: 30px;">
|
||||
<div style="text-align: center; display: flex; flex-direction: column; gap: 10px;">
|
||||
<h1 style="font-size: 2.2rem; font-weight: 800; background: var(--primary-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">Create Account</h1>
|
||||
<p style="color: var(--text-muted); font-size: 0.95rem;">Join ScoutIQ Investor Platform</p>
|
||||
</div>
|
||||
|
||||
<?php if ($flashError = $this->session->getFlash('error')): ?>
|
||||
<div class="alert alert-error">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 10px;"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
<span><?= $this->escape($flashError) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form action="/register" method="POST" style="display: flex; flex-direction: column; gap: 20px;">
|
||||
<input type="hidden" name="_csrf" value="<?= $this->session->getCsrfToken() ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="name">Full Name</label>
|
||||
<input type="text" name="name" id="name" class="form-control" placeholder="John Doe" required autocomplete="name">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="email">Email Address</label>
|
||||
<input type="email" name="email" id="email" class="form-control" placeholder="name@domain.com" required autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input type="password" name="password" id="password" class="form-control" placeholder="••••••••" required autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 10px;">Create Account</button>
|
||||
</form>
|
||||
|
||||
<div style="text-align: center; font-size: 0.9rem; color: var(--text-muted);">
|
||||
Already have an account? <a href="/login" style="font-weight: 600;">Sign In</a>
|
||||
</div>
|
||||
</div>
|
||||
17
resources/views/errors/403.php
Normal file
17
resources/views/errors/403.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Access Forbidden - ScoutIQ</title>
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
</head>
|
||||
<body style="align-items: center; justify-content: center; display: flex; padding: 20px; min-height: 100vh;">
|
||||
<div class="glass-panel" style="max-width: 500px; width: 100%; padding: 50px; text-align: center; display: flex; flex-direction: column; gap: 30px;">
|
||||
<span style="font-size: 5rem; font-weight: 800; background: var(--primary-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-family: 'Outfit';">403</span>
|
||||
<h1 style="font-size: 1.8rem; font-weight: 700;">Access Forbidden</h1>
|
||||
<p style="color: var(--text-muted); font-size: 1rem; line-height: 1.6;">You do not have the required role permissions to access this directory or perform this action.</p>
|
||||
<a href="/" class="btn btn-primary" style="align-self: center;">Return Home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
17
resources/views/errors/404.php
Normal file
17
resources/views/errors/404.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Page Not Found - ScoutIQ</title>
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
</head>
|
||||
<body style="align-items: center; justify-content: center; display: flex; padding: 20px; min-height: 100vh;">
|
||||
<div class="glass-panel" style="max-width: 500px; width: 100%; padding: 50px; text-align: center; display: flex; flex-direction: column; gap: 30px;">
|
||||
<span style="font-size: 5rem; font-weight: 800; background: var(--primary-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-family: 'Outfit';">404</span>
|
||||
<h1 style="font-size: 1.8rem; font-weight: 700;">Page Not Found</h1>
|
||||
<p style="color: var(--text-muted); font-size: 1rem; line-height: 1.6;">The resource you are looking for might have been removed, had its name changed, or is temporarily unavailable.</p>
|
||||
<a href="/" class="btn btn-primary" style="align-self: center;">Return Home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
17
resources/views/errors/500.php
Normal file
17
resources/views/errors/500.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Server Error - ScoutIQ</title>
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
</head>
|
||||
<body style="align-items: center; justify-content: center; display: flex; padding: 20px; min-height: 100vh;">
|
||||
<div class="glass-panel" style="max-width: 500px; width: 100%; padding: 50px; text-align: center; display: flex; flex-direction: column; gap: 30px;">
|
||||
<span style="font-size: 5rem; font-weight: 800; background: var(--primary-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-family: 'Outfit';">500</span>
|
||||
<h1 style="font-size: 1.8rem; font-weight: 700;">System Server Error</h1>
|
||||
<p style="color: var(--text-muted); font-size: 1rem; line-height: 1.6;"><?= isset($message) ? $this->escape($message) : 'A critical exception has occurred on the application server.' ?></p>
|
||||
<a href="/" class="btn btn-primary" style="align-self: center;">Return Home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
75
resources/views/layouts/admin.php
Normal file
75
resources/views/layouts/admin.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $this->escape($title ?? 'Admin') ?> - ScoutIQ</title>
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
<link rel="stylesheet" href="/assets/css/admin.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="color: var(--accent);"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
|
||||
<span>ScoutIQ</span>
|
||||
</div>
|
||||
|
||||
<nav style="flex: 1;">
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-item active">
|
||||
<a href="/admin/dashboard">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9"></rect><rect x="14" y="3" width="7" height="5"></rect><rect x="14" y="12" width="7" height="9"></rect><rect x="3" y="16" width="7" height="5"></rect></svg>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="#crm">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||
<span>CRM</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="#opportunities">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||||
<span>Opportunities</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="nav-menu">
|
||||
<li class="nav-item">
|
||||
<a href="/logout" style="color: var(--error);">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Top bar -->
|
||||
<header class="top-bar">
|
||||
<div style="color: var(--text-muted); font-size: 0.9rem;">
|
||||
Platform Mode: <span style="color: var(--accent); font-weight: 600; text-transform: uppercase;"><?= $this->escape($_ENV['APP_ENV'] ?? 'local') ?></span>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span style="font-weight: 500; font-size: 0.95rem;"><?= $this->escape($user['name'] ?? 'User') ?></span>
|
||||
<div class="avatar">
|
||||
<?= strtoupper(substr($user['name'] ?? 'U', 0, 1)) ?>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- View content body -->
|
||||
<div class="content-body">
|
||||
<?= $content ?>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
14
resources/views/layouts/auth.php
Normal file
14
resources/views/layouts/auth.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $this->escape($title ?? 'Auth') ?> - ScoutIQ</title>
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div style="flex: 1; display: flex; align-items: center; justify-content: center; padding: 20px;">
|
||||
<?= $content ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user