Compare commits

..

40 Commits

Author SHA1 Message Date
a05313e3a1 Merge pull request 'feat: entity move' (#42) from feat/entity-move into main
Some checks failed
Renovate / renovate (push) Failing after 1m16s
Reviewed-on: #42
2026-03-28 00:41:55 +00:00
dfba1d43be feat: entity move
All checks were successful
JS Unit Tests / test (pull_request) Successful in 20s
Build Test / build (pull_request) Successful in 23s
PHP Unit Tests / test (pull_request) Successful in 48s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-27 20:37:26 -04:00
6d27b64355 Merge pull request 'refactor: improvemets' (#41) from refactor/improvements into main
All checks were successful
Renovate / renovate (push) Successful in 1m57s
Reviewed-on: #41
2026-03-24 23:15:11 +00:00
5254b859d2 refactor: improvemets
All checks were successful
Build Test / build (pull_request) Successful in 52s
JS Unit Tests / test (pull_request) Successful in 52s
PHP Unit Tests / test (pull_request) Successful in 52s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-24 19:13:19 -04:00
bcb3431aaa Merge pull request 'feat: improve mail resource interface' (#40) from feat/improve-mail-resource-interface into main
Some checks failed
Renovate / renovate (push) Failing after 1m21s
Reviewed-on: #40
2026-03-07 03:56:11 +00:00
e770661859 Merge pull request 'feat: add prefixed route' (#39) from feat/dav-implementation into main
Reviewed-on: #39
2026-03-07 03:56:00 +00:00
1d706de663 feat: improve mail resource interface
All checks were successful
JS Unit Tests / test (pull_request) Successful in 17s
Build Test / build (pull_request) Successful in 20s
PHP Unit Tests / test (pull_request) Successful in 48s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-06 22:50:08 -05:00
ce5c5b3746 feat: add prefixed route
All checks were successful
JS Unit Tests / test (pull_request) Successful in 19s
Build Test / build (pull_request) Successful in 21s
PHP Unit Tests / test (pull_request) Successful in 49s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-06 22:48:16 -05:00
a533d0dd89 Merge pull request 'fix: user settings' (#38) from fix/user-settings into main
All checks were successful
Renovate / renovate (push) Successful in 1m23s
Reviewed-on: #38
2026-03-05 01:35:34 +00:00
3a2739fdd8 fix: user settings
All checks were successful
JS Unit Tests / test (pull_request) Successful in 15s
Build Test / build (pull_request) Successful in 19s
PHP Unit Tests / test (pull_request) Successful in 50s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-04 20:35:00 -05:00
24e046792b Merge pull request 'refactor: documents' (#37) from recator/documents into main
Reviewed-on: #37
2026-03-04 03:14:19 +00:00
1f3e87535b refactor: documents
All checks were successful
JS Unit Tests / test (pull_request) Successful in 21s
Build Test / build (pull_request) Successful in 25s
PHP Unit Tests / test (pull_request) Successful in 46s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-03-03 22:13:36 -05:00
85e89dca87 Merge pull request 'refactor: people provider' (#36) from chore/recator-people-provider into main
Some checks failed
Renovate / renovate (push) Failing after 1m18s
Reviewed-on: #36
2026-02-25 05:21:52 +00:00
e48ee82530 refactor: people provider
All checks were successful
JS Unit Tests / test (pull_request) Successful in 16s
Build Test / build (pull_request) Successful in 18s
PHP Unit Tests / test (pull_request) Successful in 56s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-25 00:13:51 -05:00
7799787ffb Merge pull request 'refactor: standardize json types' (#35) from refactor/standardize-types into main
Some checks failed
Renovate / renovate (push) Failing after 1m37s
Reviewed-on: #35
2026-02-24 22:41:40 +00:00
e996774881 refactor: standardize json types
All checks were successful
Build Test / build (pull_request) Successful in 19s
JS Unit Tests / test (pull_request) Successful in 17s
PHP Unit Tests / test (pull_request) Successful in 49s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-24 17:40:14 -05:00
2530680c0b Merge pull request 'feat: theming' (#34) from feat/theming into main
Some checks failed
Renovate / renovate (push) Failing after 1h14m0s
Reviewed-on: #34
2026-02-23 02:26:03 +00:00
b68ac538ce feat: theming
All checks were successful
JS Unit Tests / test (pull_request) Successful in 17s
Build Test / build (pull_request) Successful in 19s
PHP Unit Tests / test (pull_request) Successful in 54s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-22 21:25:26 -05:00
6975800ce5 Merge pull request 'refactor: drop unused css styles' (#33) from refactor/css-styles into main
Reviewed-on: #33
2026-02-22 22:33:10 +00:00
8be5b0b4ee refactor: drop unused css styles
All checks were successful
JS Unit Tests / test (pull_request) Successful in 15s
Build Test / build (pull_request) Successful in 19s
PHP Unit Tests / test (pull_request) Successful in 49s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-22 17:32:35 -05:00
779249a7e7 Merge pull request 'refactor: module federation' (#32) from refactor/module-federation into main
Reviewed-on: #32
2026-02-22 22:11:56 +00:00
7696c31332 Merge pull request 'refactor: login redirect' (#31) from recator/login-redirect into main
Reviewed-on: #31
2026-02-22 22:11:37 +00:00
aa52693dd9 refactor: login redirect
All checks were successful
JS Unit Tests / test (pull_request) Successful in 15s
Build Test / build (pull_request) Successful in 20s
PHP Unit Tests / test (pull_request) Successful in 53s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-22 12:37:27 -05:00
bb05f3d20f refactor: module federation
All checks were successful
JS Unit Tests / test (pull_request) Successful in 16s
Build Test / build (pull_request) Successful in 20s
PHP Unit Tests / test (pull_request) Successful in 54s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-22 12:35:56 -05:00
8560aef5e2 Merge pull request 'feat: improve module management' (#30) from feat/improve-module-management into main
Reviewed-on: #30
2026-02-22 05:41:38 +00:00
c687bd0795 feat: improve module management
All checks were successful
JS Unit Tests / test (pull_request) Successful in 39s
Build Test / build (pull_request) Successful in 44s
PHP Unit Tests / test (pull_request) Successful in 53s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-22 00:40:38 -05:00
f44fd85dcc Merge pull request 'feat/bunch-of-changes' (#29) from feat/bunch-of-changes into main
All checks were successful
Renovate / renovate (push) Successful in 1m22s
Reviewed-on: #29
2026-02-21 14:18:57 +00:00
d81e894c81 feat: unify kernel entry
All checks were successful
JS Unit Tests / test (pull_request) Successful in 38s
Build Test / build (pull_request) Successful in 41s
PHP Unit Tests / test (pull_request) Successful in 50s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 09:18:26 -05:00
c310b96a26 fix: message flags
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 09:18:26 -05:00
8e931f6650 feat: add entity streaming
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 09:18:26 -05:00
a576d85d74 improve loggers
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-21 09:18:26 -05:00
c3241862ca Merge pull request 'feat/improve-authentication' (#28) from feat/improve-authentication into main
Some checks failed
Renovate / renovate (push) Failing after 1h13m58s
Reviewed-on: #28
2026-02-20 05:04:16 +00:00
99fa707eb3 feat: improve authentication
All checks were successful
Build Test / build (pull_request) Successful in 43s
JS Unit Tests / test (pull_request) Successful in 41s
PHP Unit Tests / test (pull_request) Successful in 49s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-19 23:03:09 -05:00
decda8becc feat: cli tenant support
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-19 00:18:32 -05:00
cdbf01111f Merge pull request 'chore: standardize chrono provider' (#27) from chore/standardize-chrono-provider into main
All checks were successful
Renovate / renovate (push) Successful in 1m33s
Reviewed-on: #27
2026-02-17 08:15:14 +00:00
18f8f0e1d1 chore: standardize chrono provider
All checks were successful
JS Unit Tests / test (pull_request) Successful in 13s
Build Test / build (pull_request) Successful in 17s
PHP Unit Tests / test (pull_request) Successful in 48s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-17 03:09:38 -05:00
11014f070d Merge pull request 'chore: revert symfony console version' (#26) from chore/revert-symfony-console into main
Reviewed-on: #26
2026-02-16 00:02:10 +00:00
2b539bc883 chore: revert symfony console version
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-15 19:01:39 -05:00
3a416f3c0b Merge pull request 'chore: revert phpunit version' (#25) from chore/revert-phpunit-version into main
Reviewed-on: #25
2026-02-15 23:55:41 +00:00
9065ab69f5 chore: revert phpunit version
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-02-15 18:55:12 -05:00
202 changed files with 6442 additions and 4702 deletions

View File

@@ -3,21 +3,12 @@
/**
* Console Entry Point
*
* Bootstraps the application container and registers console commands
* from core and modules using lazy loading via Symfony Console.
*/
declare(strict_types=1);
use KTXC\Application;
use KTXC\Kernel;
use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Command\LazyCommand;
use KTXC\Server;
// Check dependencies
if (!is_dir(dirname(__DIR__).'/vendor')) {
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
exit(1);
@@ -26,92 +17,12 @@ if (!is_dir(dirname(__DIR__).'/vendor')) {
require_once dirname(__DIR__).'/vendor/autoload.php';
try {
// Bootstrap the application
$projectRoot = dirname(__DIR__);
$app = new Application($projectRoot);
// Boot kernel to initialize container and modules
$app->kernel()->boot();
// Get the container
$container = $app->container();
// Create Symfony Console Application
$console = new ConsoleApplication('Ktrix Console', Kernel::VERSION);
// Collect all command classes
$commandClasses = [];
// Collect commands from modules
/** @var ModuleManager $moduleManager */
$moduleManager = $container->get(ModuleManager::class);
foreach ($moduleManager->list() as $module) {
$moduleInstance = $module->instance();
// Skip if module instance is not available
if ($moduleInstance === null) {
continue;
}
// Check if module implements console command provider
if ($moduleInstance instanceof ModuleConsoleInterface) {
try {
$commands = $moduleInstance->registerCI();
foreach ($commands as $commandClass) {
if (!class_exists($commandClass)) {
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
continue;
}
$commandClasses[] = $commandClass;
}
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
}
}
}
// Register commands using lazy loading
foreach ($commandClasses as $commandClass) {
try {
// Use reflection to read #[AsCommand] attribute without instantiation
$reflection = new \ReflectionClass($commandClass);
$attributes = $reflection->getAttributes(\Symfony\Component\Console\Attribute\AsCommand::class);
if (empty($attributes)) {
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
continue;
}
// Get attribute instance
/** @var \Symfony\Component\Console\Attribute\AsCommand $commandAttr */
$commandAttr = $attributes[0]->newInstance();
// Create lazy command wrapper that defers instantiation
$lazyCommand = new LazyCommand(
$commandAttr->name,
[],
$commandAttr->description ?? '',
$commandAttr->hidden ?? false,
fn() => $container->get($commandClass) // Only instantiate when executed
);
$console->add($lazyCommand);
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
}
}
// Run the console application
$exitCode = $console->run();
exit($exitCode);
$server = new Server(dirname(__DIR__));
exit($server->runConsole());
} catch (\Throwable $e) {
fwrite(STDERR, "Fatal error: " . $e->getMessage() . "\n");
if (isset($app) && $app->debug()) {
fwrite(STDERR, $e->getTraceAsString() . "\n");
fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
if (isset($server) && $server->debug()) {
fwrite(STDERR, $e->getTraceAsString()."\n");
}
exit(1);
}

View File

@@ -11,7 +11,7 @@
"mongodb/mongodb": "^2.1",
"php-di/php-di": "*",
"phpseclib/phpseclib": "^3.0",
"symfony/console": "^8.0"
"symfony/console": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0"

868
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,43 @@ return [
//'cache.persistent' => 'file',
//'cache.blob' => 'file',
// Logging Configuration
'log' => [
// Driver: 'file' | 'systemd' | 'syslog' | 'null'
// file - writes JSONL to a local file (see 'path' / 'channel' below)
// systemd - writes to stderr with <priority> prefix; journald/systemd parses this natively
// syslog - writes via PHP syslog() (see 'ident' / 'facility' below)
// null - discards all log messages (useful in tests / CI)
'driver' => 'file',
// Minimum PSR-3 log level to record.
// Messages below this severity are silently discarded.
// From most to least severe: emergency, alert, critical, error, warning, notice, info, debug
'level' => 'warning',
// Per-tenant log files.
// When true, messages are written to:
// {path}/tenant/{tenantIdentifier}/{channel}.jsonl
// once a tenant session is active. Pre-tenant messages (boot phase,
// unknown domain rejections, etc.) fall through to the global log file.
'per_tenant' => false,
// ── file driver options ────────────────────────────────────────────────
// Absolute path to the log directory. null = <project_root>/var/log
'path' => null,
// Log channel — used as the filename without extension.
// 'app' → var/log/app.jsonl (or var/log/tenant/{id}/app.jsonl when per_tenant = true)
'channel' => 'app',
// ── syslog driver options ──────────────────────────────────────────────
// Identity tag passed to openlog(); visible in /var/log/syslog and journalctl -t ktrix
'ident' => 'ktrix',
// openlog() facility constant. Common values: LOG_USER, LOG_LOCAL0 … LOG_LOCAL7
'facility' => LOG_USER,
],
// Security Configuration
'security.salt' => 'a5418ed8c120b9d12c793ccea10571b74d0dcd4a4db7ca2f75e80fbdafb2bd9b',
];

View File

@@ -1,216 +0,0 @@
<?php
namespace KTXC;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use Psr\Container\ContainerInterface;
/**
* Application class - entry point for the framework
* Handles configuration loading and kernel lifecycle
*/
class Application
{
private static $composerLoader = null;
private Kernel $kernel;
private array $config;
private string $rootDir;
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
{
$this->rootDir = $this->resolveProjectRoot($rootDir);
// Load configuration
$this->config = $this->loadConfig();
// Determine environment and debug mode
$environment = $environment ?? $this->config['environment'] ?? 'prod';
$debug = $debug ?? $this->config['debug'] ?? false;
// Create kernel with configuration
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
}
/**
* Run the application - handle incoming request and send response
*/
public function run(): void
{
try {
$request = Request::createFromGlobals();
$response = $this->handle($request);
$response->send();
$this->terminate();
} catch (\Throwable $e) {
// Last resort error handling for kernel initialization failures
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
$content = $this->kernel->debug()
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
: 'An error occurred. Please try again later.';
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
$response->send();
exit(1);
}
}
/**
* Handle a request
*/
public function handle(Request $request): Response
{
return $this->kernel->handle($request);
}
/**
* Terminate the application - process deferred events
*/
public function terminate(): void
{
$this->kernel->processEvents();
}
/**
* Get the kernel instance
*/
public function kernel(): Kernel
{
return $this->kernel;
}
/**
* Get the container instance
*/
public function container(): ContainerInterface
{
return $this->kernel->container();
}
/**
* Get the application root directory
*/
public function rootDir(): string
{
return $this->rootDir;
}
/**
* Get the modules directory
*/
public function moduleDir(): string
{
return $this->rootDir . '/modules';
}
/**
* Get configuration value
*/
public function config(?string $key = null, mixed $default = null): mixed
{
if ($key === null) {
return $this->config;
}
// Support dot notation: 'database.uri'
$keys = explode('.', $key);
$value = $this->config;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Get environment
*/
public function environment(): string
{
return $this->kernel->environment();
}
/**
* Check if debug mode is enabled
*/
public function debug(): bool
{
return $this->kernel->debug();
}
/**
* Load configuration from config directory
*/
protected function loadConfig(): array
{
$configFile = $this->rootDir . '/config/system.php';
if (!file_exists($configFile)) {
error_log('Configuration file not found: ' . $configFile);
return [];
}
$config = include $configFile;
if (!is_array($config)) {
throw new \RuntimeException('Configuration file must return an array');
}
return $config;
}
/**
* Resolve the project root directory.
*
* Some entrypoints may pass the public/ directory or another subdirectory.
* We walk up the directory tree until we find composer.json.
*/
private function resolveProjectRoot(string $startDir): string
{
$dir = rtrim($startDir, '/');
if ($dir === '') {
return $startDir;
}
// If startDir is a file path, use its directory.
if (is_file($dir)) {
$dir = dirname($dir);
}
$current = $dir;
while (true) {
if (is_file($current . '/composer.json')) {
return $current;
}
$parent = dirname($current);
if ($parent === $current) {
// Reached filesystem root
return $dir;
}
$current = $parent;
}
}
/**
* Set the Composer ClassLoader instance
*/
public static function setComposerLoader($loader): void
{
self::$composerLoader = $loader;
}
/**
* Get the Composer ClassLoader instance
*/
public static function getComposerLoader()
{
return self::$composerLoader;
}
}

View File

@@ -54,8 +54,7 @@ class ModuleDisableCommand extends Command
}
// Find the module
$modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false);
$module = $modules[$handle] ?? null;
$module = $this->moduleManager->fetch($handle);
if (!$module) {
$io->error("Module '{$handle}' not found or not installed.");

View File

@@ -48,8 +48,7 @@ class ModuleEnableCommand extends Command
try {
// Find the module
$modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false);
$module = $modules[$handle] ?? null;
$module = $this->moduleManager->fetch($handle);
if (!$module) {
$io->error("Module '{$handle}' not found or not installed.");

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Module Install Command
*
* Installs a module from the filesystem.
*/
#[AsCommand(
name: 'module:install',
description: 'Install a module',
)]
class ModuleInstallCommand extends Command
{
public function __construct(
private readonly ModuleManager $moduleManager,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('handle', InputArgument::REQUIRED, 'Module handle to install')
->setHelp('This command installs a module from the filesystem.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$handle = $input->getArgument('handle');
$io->title('Install Module');
try {
// Prevent installing core module
if ($handle === 'core') {
$io->error('Cannot install the core module.');
return Command::FAILURE;
}
// Check if the module is already installed
$module = $this->moduleManager->fetch($handle);
if ($module) {
$io->warning("Module '{$handle}' is already installed.");
return Command::SUCCESS;
}
// Install the module
$io->text("Installing module '{$handle}'...");
$this->moduleManager->install($handle);
$this->logger->info('Module installed via console', [
'handle' => $handle,
'command' => $this->getName(),
]);
$io->success("Module '{$handle}' installed successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to install module: ' . $e->getMessage());
$this->logger->error('Module install failed', [
'handle' => $handle,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}

View File

@@ -45,10 +45,7 @@ class ModuleListCommand extends Command
$io->title('Installed Modules');
try {
$modules = $this->moduleManager->list(
installedOnly: true,
enabledOnly: !$showAll
);
$modules = $this->moduleManager->list();
if (count($modules) === 0) {
$io->warning('No modules found.');

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Module Uninstall Command
*
* Uninstalls an installed module.
*/
#[AsCommand(
name: 'module:uninstall',
description: 'Uninstall a module',
)]
class ModuleUninstallCommand extends Command
{
public function __construct(
private readonly ModuleManager $moduleManager,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('handle', InputArgument::REQUIRED, 'Module handle to uninstall')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
->setHelp('This command uninstalls an installed module.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$handle = $input->getArgument('handle');
$force = $input->getOption('force');
$io->title('Uninstall Module');
try {
// Prevent uninstalling core module
if ($handle === 'core') {
$io->error('Cannot uninstall the core module.');
return Command::FAILURE;
}
// Find the module
$module = $this->moduleManager->fetch($handle);
if (!$module) {
$io->error("Module '{$handle}' not found or not installed.");
return Command::FAILURE;
}
// Confirm unless --force is passed
if (!$force && !$io->confirm("Are you sure you want to uninstall module '{$handle}'?", false)) {
$io->text('Uninstall cancelled.');
return Command::SUCCESS;
}
// Uninstall the module
$io->text("Uninstalling module '{$handle}'...");
$this->moduleManager->uninstall($handle);
$this->logger->info('Module uninstalled via console', [
'handle' => $handle,
'command' => $this->getName(),
]);
$io->success("Module '{$handle}' uninstalled successfully!");
return Command::SUCCESS;
} catch (\Throwable $e) {
$io->error('Failed to uninstall module: ' . $e->getMessage());
$this->logger->error('Module uninstall failed', [
'handle' => $handle,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace KTXC\Console;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Module Upgrade Command
*
* Upgrades an installed module to its latest version.
*/
#[AsCommand(
name: 'module:upgrade',
description: 'Upgrade a module',
)]
class ModuleUpgradeCommand extends Command
{
public function __construct(
private readonly ModuleManager $moduleManager,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('handle', InputArgument::OPTIONAL, 'Module handle to upgrade (omit to upgrade all)')
->addOption('all', 'a', InputOption::VALUE_NONE, 'Upgrade all modules that need upgrading')
->setHelp('This command upgrades an installed module. Use --all to upgrade all modules that need upgrading.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$handle = $input->getArgument('handle');
$all = $input->getOption('all');
$io->title('Upgrade Module');
try {
if ($all || !$handle) {
return $this->upgradeAll($io);
}
return $this->upgradeOne($io, $handle);
} catch (\Throwable $e) {
$io->error('Failed to upgrade module: ' . $e->getMessage());
$this->logger->error('Module upgrade failed', [
'handle' => $handle,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
private function upgradeOne(SymfonyStyle $io, string $handle): int
{
// Find the module
$module = $this->moduleManager->fetch($handle);
if (!$module) {
$io->error("Module '{$handle}' not found or not installed.");
return Command::FAILURE;
}
if (!$module->needsUpgrade()) {
$io->success("Module '{$handle}' is already up to date.");
return Command::SUCCESS;
}
$io->text("Upgrading module '{$handle}'...");
$this->moduleManager->upgrade($handle);
$this->logger->info('Module upgraded via console', [
'handle' => $handle,
'command' => $this->getName(),
]);
$io->success("Module '{$handle}' upgraded successfully!");
return Command::SUCCESS;
}
private function upgradeAll(SymfonyStyle $io): int
{
$modules = $this->moduleManager->list();
$pending = [];
foreach ($modules as $module) {
if ($module->needsUpgrade()) {
$pending[] = $module->handle();
}
}
if (count($pending) === 0) {
$io->success('All modules are up to date.');
return Command::SUCCESS;
}
$io->text(sprintf('Found %d module(s) to upgrade: %s', count($pending), implode(', ', $pending)));
$failed = [];
foreach ($pending as $handle) {
try {
$io->text("Upgrading module '{$handle}'...");
$this->moduleManager->upgrade($handle);
$this->logger->info('Module upgraded via console', [
'handle' => $handle,
'command' => $this->getName(),
]);
$io->text("<fg=green>✓ '{$handle}' upgraded.</>");
} catch (\Throwable $e) {
$failed[] = $handle;
$io->text("<fg=red>✗ '{$handle}' failed: {$e->getMessage()}</>");
$this->logger->error('Module upgrade failed', [
'handle' => $handle,
'error' => $e->getMessage(),
]);
}
}
if (count($failed) > 0) {
$io->error(sprintf('Failed to upgrade %d module(s): %s', count($failed), implode(', ', $failed)));
return Command::FAILURE;
}
$io->success(sprintf('Successfully upgraded %d module(s).', count($pending)));
return Command::SUCCESS;
}
}

View File

@@ -225,7 +225,7 @@ class AuthenticationController extends ControllerAbstract
return $this->clearTokenCookies($httpResponse);
}
$httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed']);
$httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed', 'expires_in' => 900]);
if ($response->tokens && isset($response->tokens['access'])) {
$httpResponse->headers->setCookie(
@@ -242,6 +242,15 @@ class AuthenticationController extends ControllerAbstract
return $httpResponse;
}
/**
* Session health check
*/
#[AuthenticatedRoute('/auth/ping', name: 'auth.ping', methods: ['GET'])]
public function ping(): JsonResponse
{
return new JsonResponse(['status' => 'ok']);
}
/**
* Logout current device
*/
@@ -281,14 +290,16 @@ class AuthenticationController extends ControllerAbstract
*/
private function buildJsonResponse(AuthenticationResponse $response): JsonResponse
{
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
$data = $response->toArray();
// Set token cookies if present
// Set token cookies and expose expires_in if present
if ($response->hasTokens()) {
$data['expires_in'] = 900;
$httpResponse = new JsonResponse($data, $response->httpStatus);
return $this->setTokenCookies($httpResponse, $response->tokens, true);
}
return $httpResponse;
return new JsonResponse($data, $response->httpStatus);
}
/**

View File

@@ -29,7 +29,7 @@ class InitController extends ControllerAbstract
// modules - filter by permissions
$configuration['modules'] = [];
foreach ($this->moduleManager->list() as $module) {
foreach ($this->moduleManager->list(true, true) as $module) {
// Check if user has permission to view this module
// Allow access if user has: {module_handle}, {module_handle}.*, or * permission
$handle = $module->handle();
@@ -60,7 +60,7 @@ class InitController extends ControllerAbstract
'permissions' => $this->userIdentity->identity()->getPermissions(),
],
'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()),
'settings' => $this->userService->fetchSettings(),
'settings' => $this->userService->fetchSettings([], true),
];
return new JsonResponse($configuration);

View File

@@ -21,7 +21,7 @@ class ModuleController extends ControllerAbstract
)]
public function index(): JsonResponse
{
$modules = $this->moduleManager->list(false);
$modules = $this->moduleManager->list();
return new JsonResponse(['modules' => $modules]);
}

View File

@@ -0,0 +1,74 @@
<?php
namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\TenantService;
use KTXC\SessionTenant;
use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute;
/**
* Tenant-scoped settings controller.
*
* Mirrors UserSettingsController but operates on the current tenant record
* rather than the current user. Write access is guarded by the
* `tenant.settings.update` permission so only administrators can mutate
* tenant-wide configuration.
*/
class TenantSettingsController extends ControllerAbstract
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly TenantService $tenantService,
) {}
/**
* Retrieve all settings for the current tenant.
*
* @return JsonResponse Settings data as key-value pairs
*/
#[AuthenticatedRoute(
'/tenant/settings',
name: 'tenant.settings.read',
methods: ['GET'],
permissions: ['tenant.settings.read'],
)]
public function read(): JsonResponse
{
$settings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier());
return new JsonResponse($settings, JsonResponse::HTTP_OK);
}
/**
* Update one or more settings for the current tenant.
*
* @param array $data Key-value pairs to persist
*
* @example request body:
* {
* "data": {
* "default_mode": "dark",
* "primary_color": "#6366F1",
* "lock_user_colors": true
* }
* }
*
* @return JsonResponse The updated values that were written
*/
#[AuthenticatedRoute(
'/tenant/settings',
name: 'tenant.settings.update',
methods: ['PUT', 'PATCH'],
permissions: ['tenant.settings.update'],
)]
public function update(array $data): JsonResponse
{
$this->tenantService->storeSettings($this->tenantIdentity->identifier(), $data);
$updatedSettings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier(), array_keys($data));
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
}
}

View File

@@ -2,6 +2,7 @@
namespace KTXC\Controllers;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity;
@@ -18,7 +19,7 @@ class UserSettingsController extends ControllerAbstract
) {}
/**
* Retrieve user settings
* Retrieve user settings, with optional filtering
* If no specific settings are requested, all settings are returned
*
* @return JsonResponse Settings data as key-value pairs
@@ -29,10 +30,9 @@ class UserSettingsController extends ControllerAbstract
methods: ['GET'],
permissions: ['user.settings.read']
)]
public function read(): JsonResponse
public function read(bool $flatten = false): JsonResponse
{
// Fetch all settings (no filter)
$settings = $this->userService->fetchSettings();
$settings = $this->userService->fetchSettings(flatten: $flatten);
return new JsonResponse($settings, JsonResponse::HTTP_OK);
}
@@ -55,17 +55,17 @@ class UserSettingsController extends ControllerAbstract
*/
#[AuthenticatedRoute(
'/user/settings',
name: 'user.settings.update',
methods: ['PUT', 'PATCH'],
permissions: ['user.settings.update']
name: 'user.settings.write',
methods: ['POST', 'PUT', 'PATCH'],
permissions: ['user.settings.write']
)]
public function update(array $data): JsonResponse
public function write(array $data): JsonResponse
{
$this->userService->storeSettings($data);
// Return updated settings
$updatedSettings = $this->userService->fetchSettings(array_keys($data));
$settings = $this->userService->fetchSettings(array_keys($data));
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
return new JsonResponse($settings, JsonResponse::HTTP_OK);
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types = 1);
namespace KTXC\Http\Response;
/**
* StreamedNdJsonResponse streams an HTTP response as Newline Delimited JSON (NDJSON).
*
* Each item yielded by the provided iterable is serialized as a single JSON value
* followed by a newline character (\n). The response is flushed to the client every
* $flushInterval items so consumers can process records incrementally without waiting
* for the full payload.
*
* Content-Type is set to `application/x-ndjson` and `X-Accel-Buffering: no` is added
* by default to disable nginx proxy buffering.
*
* Example usage:
*
* function records(): \Generator {
* yield ['id' => 1, 'name' => 'Alice'];
* yield ['id' => 2, 'name' => 'Bob'];
* }
*
* return new StreamedNdJsonResponse(records());
*/
class StreamedNdJsonResponse extends StreamedResponse
{
/**
* @param iterable<mixed> $items Items to serialize; each becomes one JSON line
* @param int $flushInterval Flush to client after this many items (default 10)
* @param int $status HTTP status code (default 200)
* @param array<string, string|string[]> $headers Additional HTTP headers
* @param int $encodingOptions Flags passed to json_encode()
*/
public function __construct(
iterable $items,
int $flushInterval = 10,
int $status = 200,
array $headers = [],
private readonly int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS,
) {
parent::__construct(null, $status, $headers);
if (!$this->headers->get('Content-Type')) {
$this->headers->set('Content-Type', 'application/x-ndjson');
}
if (!$this->headers->has('X-Accel-Buffering')) {
$this->headers->set('X-Accel-Buffering', 'no');
}
$encodingOptions = $this->encodingOptions;
$this->setCallback(static function () use ($items, $flushInterval, $encodingOptions): void {
$count = 0;
foreach ($items as $item) {
echo json_encode($item, \JSON_THROW_ON_ERROR | $encodingOptions) . "\n";
$count++;
if ($count >= $flushInterval) {
@ob_flush();
flush();
$count = 0;
}
}
// final flush for any remaining buffered items
if ($count > 0) {
@ob_flush();
flush();
}
});
}
}

View File

@@ -21,7 +21,8 @@ use KTXC\Injection\Container;
use Psr\Container\ContainerInterface;
use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface;
use KTXC\Logger\FileLogger;
use KTXC\Logger\LoggerFactory;
use KTXC\Logger\TenantAwareLogger;
use KTXF\Event\EventBus;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
@@ -87,10 +88,8 @@ class Kernel
$_SERVER['SHELL_VERBOSITY'] = 3;
}
// Create logger with config support
$logDir = $this->config['log.directory'] ?? $this->getLogDir();
$logChannel = $this->config['log.channel'] ?? 'app';
$this->logger = new FileLogger($logDir, $logChannel);
// Create logger from config (driver + level-filter; per-tenant wrapping applied later in DI)
$this->logger = LoggerFactory::create($this->config, $this->folderRoot());
$this->initializeErrorHandlers();
@@ -396,8 +395,23 @@ class Kernel
// Without this alias, PHP-DI will happily autowire a new empty Container when asked
Container::class => \DI\get(ContainerInterface::class),
// Use the kernel's logger instance
LoggerInterface::class => \DI\value($this->logger),
LoggerInterface::class => function (ContainerInterface $c) use ($projectDir) {
$logConfig = $this->config['log'] ?? [];
$logDir = $logConfig['path'] ?? ($projectDir . '/var/log');
$channel = $logConfig['channel'] ?? 'app';
$level = $logConfig['level'] ?? 'debug';
$perTenant = (bool) ($logConfig['per_tenant'] ?? false);
return new TenantAwareLogger(
$this->logger,
$c->get(SessionTenant::class),
$logDir,
$channel,
$level,
$perTenant,
);
},
// EventBus as singleton for consistent event handling
EventBus::class => \DI\create(EventBus::class),

View File

@@ -26,7 +26,7 @@ class FileLogger implements LoggerInterface
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.log';
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.jsonl';
}
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
@@ -40,11 +40,18 @@ class FileLogger implements LoggerInterface
public function log($level, $message, array $context = []): void
{
$timestamp = $this->formatTimestamp();
$interpolated = $this->interpolate((string)$message, $context);
// Extract tenant id injected by TenantAwareLogger; default to 'system'.
$tenantId = isset($context['__tenant']) && is_string($context['__tenant'])
? $context['__tenant']
: 'system';
unset($context['__tenant']);
$timestamp = $this->formatTimestamp();
$interpolated = $this->interpolate((string) $message, $context);
$payload = [
'time' => $timestamp,
'level' => strtolower((string)$level),
'time' => $timestamp,
'level' => strtolower((string) $level),
'tenant' => $tenantId,
'channel' => $this->channel,
'message' => $interpolated,
'context' => $this->sanitizeContext($context),
@@ -53,11 +60,12 @@ class FileLogger implements LoggerInterface
if ($json === false) {
// Fallback stringify if encoding fails (should be rare)
$json = json_encode([
'time' => $timestamp,
'level' => strtolower((string)$level),
'channel' => $this->channel,
'message' => $interpolated,
'context_error' => 'failed to encode context: '.json_last_error_msg(),
'time' => $timestamp,
'level' => strtolower((string) $level),
'tenant' => $tenantId,
'channel' => $this->channel,
'message' => $interpolated,
'context_error' => 'failed to encode context: ' . json_last_error_msg(),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{"error":"logging failure"}';
}
$this->write($json);

View File

@@ -0,0 +1,47 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
/**
* PSR-3 decorator that discards messages below a configured minimum severity.
*
* Severity ordering (lower = more severe):
* emergency(0) > alert(1) > critical(2) > error(3) > warning(4) > notice(5) > info(6) > debug(7)
*
* Example: minLevel = 'warning' passes emergency, alert, critical, error, warning
* and silently discards notice, info, debug.
*/
class LevelFilterLogger implements LoggerInterface
{
private int $minSeverity;
public function __construct(
private readonly LoggerInterface $inner,
string $minLevel = LogLevel::DEBUG,
) {
LogLevelSeverity::validate($minLevel);
$this->minSeverity = LogLevelSeverity::severity($minLevel);
}
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
public function log($level, $message, array $context = []): void
{
// Messages with severity numerically greater than minSeverity are less severe — discard them.
if (LogLevelSeverity::severity((string) $level) > $this->minSeverity) {
return;
}
$this->inner->log($level, $message, $context);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LogLevel;
/**
* Maps PSR-3 log level strings to integer severity values.
*
* Lower integer = higher severity (matches RFC 5424 / syslog convention):
* emergency = 0, alert = 1, critical = 2, error = 3,
* warning = 4, notice = 5, info = 6, debug = 7
*/
class LogLevelSeverity
{
private const MAP = [
LogLevel::EMERGENCY => 0,
LogLevel::ALERT => 1,
LogLevel::CRITICAL => 2,
LogLevel::ERROR => 3,
LogLevel::WARNING => 4,
LogLevel::NOTICE => 5,
LogLevel::INFO => 6,
LogLevel::DEBUG => 7,
];
/**
* Returns the integer severity for a PSR-3 level string.
*
* @throws \InvalidArgumentException for unknown level strings
*/
public static function severity(string $level): int
{
$normalized = strtolower($level);
if (!array_key_exists($normalized, self::MAP)) {
throw new \InvalidArgumentException(
sprintf(
'Unknown log level "%s". Valid levels are: %s.',
$level,
implode(', ', array_keys(self::MAP))
)
);
}
return self::MAP[$normalized];
}
/**
* Validates that a level string is a known PSR-3 level.
*
* @throws \InvalidArgumentException for unknown level strings
*/
public static function validate(string $level): void
{
self::severity($level); // throws on unknown
}
/**
* Returns all valid PSR-3 level strings ordered from most to least severe.
*
* @return string[]
*/
public static function levels(): array
{
return array_keys(self::MAP);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
/**
* Creates a PSR-3 LoggerInterface instance from the application config.
*
* Reads the 'log' key from the system config array and builds:
* 1. A driver-specific inner logger (file, systemd, syslog, null).
* 2. A LevelFilterLogger decorator that discards messages below the
* configured minimum level.
*
* Per-tenant routing (TenantAwareLogger) is applied separately inside the
* DI container definition in Kernel::configureContainer(), because that
* requires access to the SessionTenant singleton which lives in the container.
*
* Supported config keys inside $config['log']:
*
* driver string 'file' | 'systemd' | 'syslog' | 'null' default: 'file'
* level string PSR-3 level string (minimum to log) default: 'debug'
* per_tenant bool Route to per-tenant files (DI-level only) default: false
*
* -- file driver --
* path ?string Absolute log directory, null = {root}/var/log
* channel string File basename without extension default: 'app'
*
* -- systemd driver --
* channel string Channel tag embedded in each line default: 'app'
*
* -- syslog driver --
* ident string openlog() identity tag default: 'ktrix'
* facility int openlog() facility constant default: LOG_USER
* channel string Prefix embedded in each syslog message default: 'app'
*/
class LoggerFactory
{
/**
* Build and return a configured, level-filtered PSR-3 logger.
*
* @param array $config The full system config array (reads $config['log']).
* @param string $projectDir Absolute project root path (used for default file path).
*/
public static function create(array $config, string $projectDir): LoggerInterface
{
$logConfig = $config['log'] ?? [];
$driver = $logConfig['driver'] ?? 'file';
$level = $logConfig['level'] ?? 'debug';
$channel = $logConfig['channel'] ?? 'app';
// Validate level early for a clear error message.
LogLevelSeverity::validate($level);
$inner = match ($driver) {
'file' => self::buildFileLogger($logConfig, $projectDir, $channel),
'systemd' => new SystemdLogger($channel),
'syslog' => new SyslogLogger(
$logConfig['ident'] ?? 'ktrix',
$logConfig['facility'] ?? LOG_USER,
$channel,
),
'null' => new NullLogger(),
default => throw new \RuntimeException(
sprintf(
'Unknown log driver "%s". Supported drivers: file, systemd, syslog, null.',
$driver
)
),
};
return new LevelFilterLogger($inner, $level);
}
private static function buildFileLogger(array $logConfig, string $projectDir, string $channel): FileLogger
{
$path = $logConfig['path'] ?? null;
if ($path === null || $path === '') {
$path = $projectDir . '/var/log';
}
return new FileLogger($path, $channel);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LoggerInterface;
/**
* Simple file-based PSR-3 logger that writes plain-text lines.
*
* Each entry is formatted as:
* 2026-02-20 12:34:56.123456 message text
*/
class PlainFileLogger implements LoggerInterface
{
private string $logFile;
/**
* @param string $logDir Directory where log files are written
* @param string $channel Logical channel name (used in filename)
*/
public function __construct(string $logDir, string $channel = 'app')
{
if (!is_dir($logDir)) {
@mkdir($logDir, 0775, true);
}
$this->logFile = rtrim($logDir, '/') . '/' . $channel . '.log';
}
public function emergency($message, array $context = []): void { $this->log('emergency', $message, $context); }
public function alert($message, array $context = []): void { $this->log('alert', $message, $context); }
public function critical($message, array $context = []): void { $this->log('critical', $message, $context); }
public function error($message, array $context = []): void { $this->log('error', $message, $context); }
public function warning($message, array $context = []): void { $this->log('warning', $message, $context); }
public function notice($message, array $context = []): void { $this->log('notice', $message, $context); }
public function info($message, array $context = []): void { $this->log('info', $message, $context); }
public function debug($message, array $context = []): void { $this->log('debug', $message, $context); }
public function log($level, $message, array $context = []): void
{
$dt = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true)));
$timestamp = $dt?->format('Y-m-d H:i:s.u') ?? date('Y-m-d H:i:s');
$line = $timestamp . ' ' . $this->interpolate((string) $message, $context) . PHP_EOL;
@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
}
private function interpolate(string $message, array $context): string
{
if (!str_contains($message, '{')) {
return $message;
}
$replace = [];
foreach ($context as $key => $val) {
if (is_array($val) || (is_object($val) && !method_exists($val, '__toString'))) {
continue;
}
$replace['{' . $key . '}'] = (string) $val;
}
return strtr($message, $replace);
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
/**
* PSR-3 logger that writes via PHP's built-in syslog() facility.
*
* Each message is prefixed with "[channel] " so entries can be filtered easily
* in /var/log/syslog (or equivalent) or via journalctl -t {ident}.
*/
class SyslogLogger implements LoggerInterface
{
/** Maps PSR-3 levels to PHP syslog priority constants */
private const PRIORITY = [
LogLevel::EMERGENCY => LOG_EMERG,
LogLevel::ALERT => LOG_ALERT,
LogLevel::CRITICAL => LOG_CRIT,
LogLevel::ERROR => LOG_ERR,
LogLevel::WARNING => LOG_WARNING,
LogLevel::NOTICE => LOG_NOTICE,
LogLevel::INFO => LOG_INFO,
LogLevel::DEBUG => LOG_DEBUG,
];
public function __construct(
private readonly string $ident = 'ktrix',
private readonly int $facility = LOG_USER,
private readonly string $channel = 'app',
) {}
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
public function log($level, $message, array $context = []): void
{
// Extract tenant id injected by TenantAwareLogger; default to 'system'.
$tenantId = isset($context['__tenant']) && is_string($context['__tenant'])
? $context['__tenant']
: 'system';
unset($context['__tenant']);
$level = strtolower((string) $level);
$priority = self::PRIORITY[$level] ?? LOG_DEBUG;
$interpolated = $this->interpolate((string) $message, $context);
$contextStr = empty($context) ? '' : ' ' . $this->encodeContext($context);
$entry = sprintf('[%s] [%s] %s%s', $this->channel, $tenantId, $interpolated, $contextStr);
openlog($this->ident, LOG_NDELAY | LOG_PID, $this->facility);
syslog($priority, $entry);
closelog();
}
private function interpolate(string $message, array $context): string
{
if (!str_contains($message, '{')) {
return $message;
}
$replace = [];
foreach ($context as $key => $val) {
if (!is_array($val) && !is_object($val)) {
$replace['{' . $key . '}'] = (string) $val;
}
}
return strtr($message, $replace);
}
private function encodeContext(array $context): string
{
$clean = [];
foreach ($context as $k => $v) {
if ($v instanceof \Throwable) {
$clean[$k] = ['type' => get_class($v), 'message' => $v->getMessage()];
} elseif (is_resource($v)) {
$clean[$k] = 'resource(' . get_resource_type($v) . ')';
} elseif (is_object($v)) {
$clean[$k] = method_exists($v, '__toString') ? (string) $v : ['object' => get_class($v)];
} else {
$clean[$k] = $v;
}
}
return json_encode($clean, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}';
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace KTXC\Logger;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
/**
* PSR-3 logger that writes to stderr using the journald/systemd SD_JOURNAL_PREFIX
* format: a syslog-priority number wrapped in angle brackets followed by the message.
*
* journald automatically parses the "<N>" prefix and maps it to the corresponding
* log priority, so log entries appear in the journal with the correct severity.
*
* Format: <priority>LEVEL [channel] interpolated-message {"context":"key",...}
*/
class SystemdLogger implements LoggerInterface
{
/** Maps PSR-3 levels to RFC 5424 / syslog priority numbers */
private const PRIORITY = [
LogLevel::EMERGENCY => 0,
LogLevel::ALERT => 1,
LogLevel::CRITICAL => 2,
LogLevel::ERROR => 3,
LogLevel::WARNING => 4,
LogLevel::NOTICE => 5,
LogLevel::INFO => 6,
LogLevel::DEBUG => 7,
];
/** @var resource */
private $stderr;
public function __construct(private readonly string $channel = 'app')
{
$this->stderr = fopen('php://stderr', 'w');
}
public function __destruct()
{
if (is_resource($this->stderr)) {
fclose($this->stderr);
}
}
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
public function log($level, $message, array $context = []): void
{
// Extract tenant id injected by TenantAwareLogger; default to 'system'.
$tenantId = isset($context['__tenant']) && is_string($context['__tenant'])
? $context['__tenant']
: 'system';
unset($context['__tenant']);
$level = strtolower((string) $level);
$priority = self::PRIORITY[$level] ?? 7;
$interpolated = $this->interpolate((string) $message, $context);
$contextStr = empty($context) ? '' : ' ' . $this->encodeContext($context);
$line = sprintf(
"<%d>%s [%s] [%s] %s%s\n",
$priority,
strtoupper($level),
$this->channel,
$tenantId,
$interpolated,
$contextStr,
);
fwrite($this->stderr, $line);
}
private function interpolate(string $message, array $context): string
{
if (!str_contains($message, '{')) {
return $message;
}
$replace = [];
foreach ($context as $key => $val) {
if (!is_array($val) && !is_object($val)) {
$replace['{' . $key . '}'] = (string) $val;
}
}
return strtr($message, $replace);
}
private function encodeContext(array $context): string
{
$clean = [];
foreach ($context as $k => $v) {
if ($v instanceof \Throwable) {
$clean[$k] = ['type' => get_class($v), 'message' => $v->getMessage()];
} elseif (is_resource($v)) {
$clean[$k] = 'resource(' . get_resource_type($v) . ')';
} elseif (is_object($v)) {
$clean[$k] = method_exists($v, '__toString') ? (string) $v : ['object' => get_class($v)];
} else {
$clean[$k] = $v;
}
}
return json_encode($clean, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}';
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace KTXC\Logger;
use KTXC\SessionTenant;
use Psr\Log\LoggerInterface;
/**
* PSR-3 decorator with two responsibilities:
*
* 1. Tenant-id injection — every log record is enriched with a `tenant` field.
* When a tenant session is active the real tenant identifier is used; otherwise
* the value is "system" (boot phase, CLI, bad-domain rejections, etc.).
* The id is passed to inner loggers via the reserved `__tenant` context key,
* which each concrete logger (FileLogger, SystemdLogger, SyslogLogger) extracts
* and renders as a top-level field, then removes from context before output.
*
* 2. Per-tenant file routing (optional, controlled by $perTenant) — when enabled,
* writes for an active tenant are routed to:
* {logDir}/tenant/{tenantIdentifier}/{channel}.jsonl
* Messages that carry tenant "system" always go to the global logger.
*
* Both behaviours rely on a live SessionTenant reference that is populated lazily
* by TenantMiddleware — the same pattern the cache stores use in Kernel::configureContainer().
*/
class TenantAwareLogger implements LoggerInterface
{
/** @var array<string, LoggerInterface> Per-tenant logger cache, keyed by tenant identifier. */
private array $tenantLoggers = [];
/**
* @param LoggerInterface $globalLogger Fallback logger (also used when perTenant = false).
* @param SessionTenant $sessionTenant Live reference configured by TenantMiddleware.
* @param string $logDir Base log directory (e.g. /var/www/app/var/log).
* @param string $channel Log file basename (e.g. 'app' → app.jsonl).
* @param string $minLevel Minimum PSR-3 level for lazily-created per-tenant loggers.
* @param bool $perTenant When true, route tenant writes to per-tenant files.
*/
public function __construct(
private readonly LoggerInterface $globalLogger,
private readonly SessionTenant $sessionTenant,
private readonly string $logDir,
private readonly string $channel = 'app',
private readonly string $minLevel = 'debug',
private readonly bool $perTenant = false,
) {
LogLevelSeverity::validate($minLevel);
}
public function emergency($message, array $context = []): void { $this->log('emergency', $message, $context); }
public function alert($message, array $context = []): void { $this->log('alert', $message, $context); }
public function critical($message, array $context = []): void { $this->log('critical', $message, $context); }
public function error($message, array $context = []): void { $this->log('error', $message, $context); }
public function warning($message, array $context = []): void { $this->log('warning', $message, $context); }
public function notice($message, array $context = []): void { $this->log('notice', $message, $context); }
public function info($message, array $context = []): void { $this->log('info', $message, $context); }
public function debug($message, array $context = []): void { $this->log('debug', $message, $context); }
public function log($level, $message, array $context = []): void
{
// Resolve current tenant id; fall back to 'system' for CLI / boot phase.
$tenantId = 'system';
if ($this->sessionTenant->configured()) {
$tenantId = $this->sessionTenant->identifier() ?? 'system';
}
// Inject tenant id as a reserved context key that concrete loggers extract.
$context['__tenant'] = $tenantId;
$this->resolveLogger($tenantId)->log($level, $message, $context);
}
/**
* Returns the logger to write to for the given tenant.
* When per-tenant routing is disabled (or tenant is "system"), always returns the global logger.
*/
private function resolveLogger(string $tenantId): LoggerInterface
{
if (!$this->perTenant || $tenantId === 'system') {
return $this->globalLogger;
}
if (!isset($this->tenantLoggers[$tenantId])) {
$tenantLogDir = rtrim($this->logDir, '/') . '/tenant/' . $tenantId;
$this->tenantLoggers[$tenantId] = new LevelFilterLogger(
new FileLogger($tenantLogDir, $this->channel),
$this->minLevel,
);
}
return $this->tenantLoggers[$tenantId];
}
}

View File

@@ -102,6 +102,9 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
\KTXC\Console\ModuleListCommand::class,
\KTXC\Console\ModuleEnableCommand::class,
\KTXC\Console\ModuleDisableCommand::class,
\KTXC\Console\ModuleInstallCommand::class,
\KTXC\Console\ModuleUninstallCommand::class,
\KTXC\Console\ModuleUpgradeCommand::class,
];
}

View File

@@ -2,7 +2,7 @@
namespace KTXC\Module;
use KTXC\Application;
use KTXC\Server;
/**
* Custom autoloader for modules that allows PascalCase namespaces
@@ -73,7 +73,7 @@ class ModuleAutoloader
}
// Register module namespaces with Composer ClassLoader
$composerLoader = \KTXC\Application::getComposerLoader();
$composerLoader = Server::getComposerLoader();
if ($composerLoader !== null) {
foreach ($this->namespaceMap as $namespace => $folderName) {
$composerLoader->addPsr4(

View File

@@ -31,9 +31,9 @@ class ModuleManager
*
* @param bool $installedOnly If true, only return modules that are in the database
* @param bool $enabledOnly If true, only return modules that are enabled (implies installedOnly)
* @return Module[]
* @return ModuleObject[]
*/
public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection
public function list(bool| null $installedOnly = null, bool| null $enabledOnly = null): ModuleCollection
{
$modules = New ModuleCollection();
@@ -44,12 +44,8 @@ class ModuleManager
}
// load all modules from store
$entries = $this->repository->list();
$entries = $this->repository->list($installedOnly, $enabledOnly);
foreach ($entries as $entry) {
if ($enabledOnly && !$entry->getEnabled()) {
continue; // Skip disabled modules if filtering for enabled only
}
// instance module
$handle = $entry->getHandle();
if (isset($this->moduleInstances[$entry->getHandle()])) {
$modules[$handle] = new ModuleObject($this->moduleInstances[$handle], $entry);
@@ -60,7 +56,7 @@ class ModuleManager
}
}
// load all modules from filesystem
if ($installedOnly === false) {
if ($installedOnly !== true) {
$discovered = $this->modulesDiscover();
foreach ($discovered as $moduleInstance) {
$handle = $moduleInstance->handle();
@@ -72,6 +68,21 @@ class ModuleManager
return $modules;
}
public function fetch(string $handle): ?ModuleObject
{
$entry = $this->repository->fetch($handle);
if (!$entry) {
return null;
}
$moduleInstance = $this->moduleInstance($entry->getHandle(), $entry->getNamespace());
if (!$moduleInstance) {
return null;
}
return new ModuleObject($moduleInstance, $entry);
}
public function install(string $handle): void
{
@@ -258,7 +269,7 @@ class ModuleManager
public function modulesBoot(): void
{
// Only load modules that are enabled in the database
$modules = $this->list();
$modules = $this->list(true, true);
$this->logger->debug('Booting enabled modules', ['count' => count($modules)]);
foreach ($modules as $module) {
$handle = $module->handle();

View File

@@ -9,10 +9,7 @@ use KTXF\Module\ModuleConsoleInterface;
use KTXF\Module\ModuleInstanceInterface;
/**
* Module is a unified wrapper that combines both the ModuleInterface instance
* (from filesystem) and ModuleEntry (from database) into a single object.
*
* This provides a single source of truth for all module information.
* Module is a unified wrapper that combines the filesystem module instance and the database module entry.
*/
class ModuleObject implements JsonSerializable
{
@@ -32,6 +29,9 @@ class ModuleObject implements JsonSerializable
return [
'id' => $this->id(),
'handle' => $this->handle(),
'label' => $this->label(),
'description' => $this->description(),
'author' => $this->author(),
'version' => $this->version(),
'namespace' => $this->namespace(),
'installed' => $this->installed(),
@@ -86,6 +86,21 @@ class ModuleObject implements JsonSerializable
return null;
}
public function label(): string
{
return $this->instance?->label() ?? '';
}
public function description(): string
{
return $this->instance?->description() ?? '';
}
public function author(): string
{
return $this->instance?->author() ?? '';
}
public function version(): string
{
// Prefer current version from filesystem

View File

@@ -3,6 +3,7 @@
namespace KTXC\Module\Store;
use KTXC\Db\DataStore;
use KTXC\Db\ObjectId;
class ModuleStore
{
@@ -13,9 +14,18 @@ class ModuleStore
protected readonly DataStore $dataStore
) { }
public function list(): array
public function list(bool|null $installed = null, bool|null $enabled = null): array
{
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(['enabled' => true, 'installed' => true]);
$filter = [];
if ($installed !== null) {
$filter['installed'] = $installed;
}
if ($enabled !== null) {
$filter['enabled'] = $enabled;
}
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($filter);
$modules = [];
foreach ($cursor as $entry) {
$entity = new ModuleEntry();
@@ -52,7 +62,11 @@ class ModuleStore
{
$id = $entry->getId();
if (!$id) { return null; }
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
$data = $entry->jsonSerialize();
unset($data['id']);
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => new ObjectId($id)], ['$set' => $data]);
return $entry;
}
@@ -60,7 +74,7 @@ class ModuleStore
{
$id = $entry->getId();
if (!$id) { return; }
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
}
}

View File

@@ -7,6 +7,7 @@ use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Injection\Container;
use KTXC\Module\ModuleManager;
use KTXF\Routing\Attributes\AnonymousPrefixRoute;
use KTXF\Routing\Attributes\AnonymousRoute;
use KTXF\Routing\Attributes\AuthenticatedRoute;
use Psr\Log\LoggerInterface;
@@ -19,6 +20,8 @@ class Router
private Container $container;
/** @var array<string,array<string,Route>> */
private array $routes = []; // [method][path] => Route
/** @var array<string,array<string,Route>> prefix routes [method][prefix] => Route */
private array $prefixRoutes = [];
private bool $initialized = false;
private string $cacheFile;
@@ -40,8 +43,9 @@ class Router
// load cached routes in production
if ($this->environment === 'prod' && file_exists($this->cacheFile)) {
$data = include $this->cacheFile;
if (is_array($data)) {
$this->routes = $data;
if (is_array($data) && isset($data['routes'])) {
$this->routes = $data['routes'];
$this->prefixRoutes = $data['prefix'] ?? [];
$this->initialized = true;
return;
}
@@ -52,7 +56,8 @@ class Router
// write cache
$dir = dirname($this->cacheFile);
if (!is_dir($dir)) @mkdir($dir, 0775, true);
file_put_contents($this->cacheFile, '<?php return ' . var_export($this->routes, true) . ';');
$cache = ['routes' => $this->routes, 'prefix' => $this->prefixRoutes];
file_put_contents($this->cacheFile, '<?php return ' . var_export($cache, true) . ';');
}
@@ -95,13 +100,17 @@ class Router
foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
$attributes = array_merge(
$reflectionMethod->getAttributes(AnonymousRoute::class),
$reflectionMethod->getAttributes(AuthenticatedRoute::class)
$reflectionMethod->getAttributes(AuthenticatedRoute::class),
$reflectionMethod->getAttributes(AnonymousPrefixRoute::class),
);
foreach ($attributes as $attribute) {
$route = $attribute->newInstance();
$httpPath = $routePrefix . $route->path;
$isPrefix = $route instanceof AnonymousPrefixRoute;
$httpPath = ($isPrefix && $route->absolute)
? $route->path
: $routePrefix . $route->path;
foreach ($route->methods as $httpMethod) {
$this->routes[$httpMethod][$httpPath] = new Route(
$routeObject = new Route(
method: $httpMethod,
path: $httpPath,
name: $route->name,
@@ -111,6 +120,11 @@ class Router
classMethodParameters: $reflectionMethod->getParameters(),
permissions: $route instanceof AuthenticatedRoute ? $route->permissions : [],
);
if ($isPrefix) {
$this->prefixRoutes[$httpMethod][$httpPath] = $routeObject;
} else {
$this->routes[$httpMethod][$httpPath] = $routeObject;
}
}
}
}
@@ -123,7 +137,7 @@ class Router
/**
* Match a Request to a Route, or return null if no match.
* Supports exact matches and simple {param} patterns.
* Prioritizes: 1) exact matches, 2) specific patterns, 3) catch-all patterns
* Prioritizes: 1) exact matches, 2) specific patterns, 3) prefix routes, 4) catch-all patterns
*/
public function match(Request $request): ?Route
{
@@ -161,6 +175,20 @@ class Router
return $routeObj->withParams($params);
}
}
// Try prefix routes before catch-all — longest matching prefix wins
$bestPrefix = null;
$bestRoute = null;
foreach ($this->prefixRoutes[$method] ?? [] as $prefix => $routeObj) {
if (str_starts_with($path, $prefix) || str_starts_with($path . '/', $prefix)) {
if ($bestPrefix === null || strlen($prefix) > strlen($bestPrefix)) {
$bestPrefix = $prefix;
$bestRoute = $routeObj;
}
}
}
if ($bestRoute !== null) {
return $bestRoute;
}
// Try catch-all pattern last
if ($catchAllPattern !== null) {
[$routePath, $routeObj] = $catchAllPattern;

View File

@@ -2,91 +2,310 @@
namespace KTXC;
use KTXC\Injection\Container;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Module\ModuleAutoloader;
use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\LazyCommand;
/**
* Legacy Server class - now a facade to Application
* @deprecated Use Application class directly
* Server class - entry point for the framework
* Handles configuration loading and kernel lifecycle
*/
class Server
{
public const ENVIRONMENT_DEV = 'dev';
public const ENVIRONMENT_PROD = 'prod';
private static $composerLoader = null;
private static ?self $instance = null;
private Kernel $kernel;
private array $config;
private string $rootDir;
/**
* @deprecated Use Application instead
*/
public static function run(): void {
trigger_error('Server::run() is deprecated. Use Application class instead.', E_USER_DEPRECATED);
$projectRoot = dirname(dirname(__DIR__));
$app = new Application($projectRoot);
$app->run();
}
/**
* @deprecated Use Application::getInstance()->environment()
*/
public static function environment(): string {
return self::app()->environment();
}
/**
* @deprecated Use Application::getInstance()->debug()
*/
public static function debug(): bool {
return self::app()->debug();
}
/**
* @deprecated Use Application::getInstance()->kernel()
*/
public static function runtimeKernel(): Kernel {
return self::app()->kernel();
}
/**
* @deprecated Use Application::getInstance()->container()
*/
public static function runtimeContainer(): Container {
return self::app()->container();
}
/**
* @deprecated Use Application::getInstance()->rootDir()
*/
public static function runtimeRootLocation(): string {
return self::app()->rootDir();
}
/**
* @deprecated Use Application::getInstance()->moduleDir()
*/
public static function runtimeModuleLocation(): string {
return self::app()->moduleDir();
}
/**
* @deprecated Use Application::setComposerLoader()
*/
public static function setComposerLoader($loader): void {
Application::setComposerLoader($loader);
}
/**
* @deprecated Use Application::getComposerLoader()
*/
public static function getComposerLoader() {
return Application::getComposerLoader();
}
private static function app(): Application
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
{
throw new \RuntimeException(
'Server class is deprecated and no longer functional. ' .
'Update your code to use Application class with proper dependency injection. ' .
'See the migration guide for details.'
);
self::$instance = $this;
$this->rootDir = $this->resolveProjectRoot($rootDir);
// Load configuration
$this->config = $this->loadConfig();
// Determine environment and debug mode
$environment = $environment ?? $this->config['environment'] ?? 'prod';
$debug = $debug ?? $this->config['debug'] ?? false;
// Create kernel with configuration
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
// Register module autoloader for both HTTP and CLI contexts
$moduleAutoloader = new ModuleAutoloader($this->moduleDir());
$moduleAutoloader->register();
}
/**
* Run the application - handle incoming request and send response
*/
public function runHttp(): void
{
try {
$request = Request::createFromGlobals();
$response = $this->handle($request);
$response->send();
$this->terminate();
} catch (\Throwable $e) {
// Last resort error handling for kernel initialization failures
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
$content = $this->kernel->debug()
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
: 'An error occurred. Please try again later.';
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
$response->send();
exit(1);
}
}
/**
* Run as a console application (CLI runtime).
*/
public function runConsole(): int
{
$this->kernel()->boot();
$container = $this->container();
$console = new ConsoleApplication('Ktrix Console', Kernel::VERSION);
/** @var ModuleManager $moduleManager */
$moduleManager = $container->get(ModuleManager::class);
foreach ($moduleManager->list() as $module) {
$instance = $module->instance();
if (!$instance instanceof ModuleConsoleInterface) {
continue;
}
try {
foreach ($instance->registerCI() as $commandClass) {
if (!class_exists($commandClass)) {
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
continue;
}
$this->registerLazyCommand($console, $container, $commandClass);
}
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
}
}
return $console->run();
}
/**
* Handle a request
*/
public function handle(Request $request): Response
{
return $this->kernel->handle($request);
}
/**
* Terminate the application - process deferred events
*/
public function terminate(): void
{
$this->kernel->processEvents();
}
/**
* Get the kernel instance
*/
public function kernel(): Kernel
{
return $this->kernel;
}
/**
* Get the container instance
*/
public function container(): ContainerInterface
{
return $this->kernel->container();
}
/**
* Get the application root directory
*/
public function rootDir(): string
{
return $this->rootDir;
}
/**
* Get the modules directory
*/
public function moduleDir(): string
{
return $this->rootDir . '/modules';
}
public function varDir(): string
{
return $this->rootDir . '/var';
}
public function logDir(): string
{
return $this->varDir() . '/logs';
}
/**
* Get configuration value
*/
public function config(?string $key = null, mixed $default = null): mixed
{
if ($key === null) {
return $this->config;
}
// Support dot notation: 'database.uri'
$keys = explode('.', $key);
$value = $this->config;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return $default;
}
$value = $value[$k];
}
return $value;
}
/**
* Get environment
*/
public function environment(): string
{
return $this->kernel->environment();
}
/**
* Check if debug mode is enabled
*/
public function debug(): bool
{
return $this->kernel->debug();
}
/**
* Load configuration from config directory
*/
protected function loadConfig(): array
{
$configFile = $this->rootDir . '/config/system.php';
if (!file_exists($configFile)) {
error_log('Configuration file not found: ' . $configFile);
return [];
}
$config = include $configFile;
if (!is_array($config)) {
throw new \RuntimeException('Configuration file must return an array');
}
return $config;
}
/**
* Resolve the project root directory.
*
* Some entrypoints may pass the public/ directory or another subdirectory.
* We walk up the directory tree until we find composer.json.
*/
private function resolveProjectRoot(string $startDir): string
{
$dir = rtrim($startDir, '/');
if ($dir === '') {
return $startDir;
}
// If startDir is a file path, use its directory.
if (is_file($dir)) {
$dir = dirname($dir);
}
$current = $dir;
while (true) {
if (is_file($current . '/composer.json')) {
return $current;
}
$parent = dirname($current);
if ($parent === $current) {
// Reached filesystem root
return $dir;
}
$current = $parent;
}
}
/**
* Set the Composer ClassLoader instance
*/
public static function setComposerLoader($loader): void
{
self::$composerLoader = $loader;
}
/**
* Get the Composer ClassLoader instance
*/
public static function getComposerLoader()
{
return self::$composerLoader;
}
/**
* Get the current Application instance
*/
public static function getInstance(): ?self
{
return self::$instance;
}
/**
* Register a single command via lazy loading using its #[AsCommand] attribute.
*/
private function registerLazyCommand(
ConsoleApplication $console,
ContainerInterface $container,
string $commandClass
): void {
try {
$ref = new \ReflectionClass($commandClass);
$attrs = $ref->getAttributes(AsCommand::class);
if (empty($attrs)) {
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
return;
}
$attr = $attrs[0]->newInstance();
$console->add(new LazyCommand(
$attr->name,
[],
$attr->description ?? '',
$attr->hidden ?? false,
fn() => $container->get($commandClass)
));
} catch (\Throwable $e) {
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
}
}
}

View File

@@ -142,4 +142,19 @@ class SecurityService
{
return $this->tokenService->validateToken($token, $this->securityCode, checkBlacklist: false);
}
/**
* Authenticate using a plain-text identity + password credential.
*
* Used by the DAV auth backend which receives credentials from the HTTP
* Basic challenge after they have been extracted from the Authorization header.
*
* @param string $identity Username / e-mail
* @param string $password Plain-text password
* @return User|null Authenticated user, or null on failure
*/
public function authenticatePassword(string $identity, string $password): ?User
{
return $this->authenticateBasic($identity, $password);
}
}

View File

@@ -7,13 +7,45 @@ use KTXC\Stores\TenantStore;
class TenantService
{
public function __construct(protected readonly TenantStore $store)
{
}
public function __construct(
protected readonly TenantStore $store,
) {}
public function fetchByDomain(string $domain): ?TenantObject
{
return $this->store->fetchByDomain($domain);
}
public function fetchById(string $identifier): ?TenantObject
{
return $this->store->fetch($identifier);
}
// =========================================================================
// Settings
// =========================================================================
/**
* Fetch all or a filtered subset of a tenant's settings.
*
* @param string $identifier Tenant identifier
* @param string[] $keys Optional list of keys to return; empty = all
*
* @return array<string, mixed>
*/
public function fetchSettings(string $identifier, array $keys = []): array
{
return $this->store->fetchSettings($identifier, $keys);
}
/**
* Merge-update settings for a tenant.
*
* @param string $identifier Tenant identifier
* @param array<string, mixed> $settings Key-value pairs to persist
*/
public function storeSettings(string $identifier, array $settings): bool
{
return $this->store->storeSettings($identifier, $settings);
}
}

View File

@@ -116,9 +116,9 @@ class UserAccountsService
// Settings Operations
// =========================================================================
public function fetchSettings(array $settings = []): array | null
public function fetchSettings(array $settings = [], bool $flatten = false): array | null
{
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings, $flatten);
}
public function storeSettings(array $settings): bool

View File

@@ -36,17 +36,17 @@ class SessionIdentity
public function mailAddress(): ?string
{
return $this->identityData?->getEmail();
return $this->identityData?->getIdentity();
}
public function nameFirst(): ?string
{
return $this->identityData?->getFirstName();
return null;
}
public function nameLast(): ?string
{
return $this->identityData?->getLastName();
return null;
}
public function permissions(): array

View File

@@ -37,6 +37,26 @@ class SessionTenant
}
}
/**
* Configure the tenant by its identifier (for console / CLI usage).
*/
public function configureById(string $identifier): void
{
if ($this->configured) {
return;
}
$tenant = $this->tenantService->fetchById($identifier);
if ($tenant) {
$this->domain = $identifier;
$this->tenant = $tenant;
$this->configured = true;
} else {
$this->domain = null;
$this->tenant = null;
$this->configured = false;
}
}
/**
* Is the tenant configured
*/

View File

@@ -72,4 +72,57 @@ class TenantStore
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
}
// =========================================================================
// Settings Operations
// =========================================================================
/**
* Fetch settings for a tenant, optionally filtered to specific keys.
*
* @param string $identifier Tenant identifier
* @param string[] $keys Optional list of keys to return; empty = all
*
* @return array<string, mixed>
*/
public function fetchSettings(string $identifier, array $keys = []): array
{
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(
['identifier' => $identifier],
['projection' => ['settings' => 1]]
);
$settings = (array)($entry['settings'] ?? []);
if (empty($keys)) {
return $settings;
}
$result = [];
foreach ($keys as $key) {
$result[$key] = $settings[$key] ?? null;
}
return $result;
}
/**
* Merge-update settings for a tenant using atomic $set on sub-fields.
*
* @param string $identifier Tenant identifier
* @param array<string, mixed> $settings Key-value pairs to persist
*/
public function storeSettings(string $identifier, array $settings): bool
{
$updates = [];
foreach ($settings as $key => $value) {
$updates["settings.{$key}"] = $value;
}
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
['identifier' => $identifier],
['$set' => $updates]
);
return $result->getMatchedCount() > 0;
}
}

View File

@@ -249,7 +249,7 @@ class UserAccountsStore
// Settings Operations
// =========================================================================
public function fetchSettings(string $tenant, string $uid, array $settings = []): ?array
public function fetchSettings(string $tenant, string $uid, array $settings = [], bool $flatten = false): ?array
{
// Only fetch the settings field from the database
$user = $this->store->selectCollection('user_accounts')->findOne(
@@ -264,12 +264,39 @@ class UserAccountsStore
$userSettings = $user['settings'] ?? [];
if (empty($settings)) {
return $userSettings;
return $flatten ? $this->flattenSettings($userSettings) : $userSettings;
}
// Specific keys are always returned in dot-notation (already flat)
$result = [];
foreach ($settings as $key) {
$result[$key] = $userSettings[$key] ?? null;
$parts = explode('.', $key);
$value = $userSettings;
foreach ($parts as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
$value = null;
break;
}
$value = $value[$part];
}
$result[$key] = $value;
}
return $result;
}
/**
* Recursively flatten a nested settings array into dot-notation keys.
*/
private function flattenSettings(array $settings, string $prefix = ''): array
{
$result = [];
foreach ($settings as $key => $value) {
$fullKey = $prefix !== '' ? "{$prefix}.{$key}" : (string) $key;
if (is_array($value)) {
$result = array_merge($result, $this->flattenSettings($value, $fullKey));
} else {
$result[$fullKey] = $value;
}
}
return $result;
}

View File

@@ -1,22 +1,11 @@
<?php
use KTXC\Application;
use KTXC\Module\ModuleAutoloader;
use KTXC\Server;
// Capture Composer ClassLoader instance
// Capture Composer ClassLoader instance for compatibility
$composerLoader = require_once __DIR__ . '/../vendor/autoload.php';
// Determine project root (one level up from this file)
$projectRoot = dirname(__DIR__);
$server = new Server(dirname(__DIR__));
Server::setComposerLoader($composerLoader);
// Create and run application
$app = new Application($projectRoot);
// Store composer loader for compatibility
Application::setComposerLoader($composerLoader);
// Register custom module autoloader for lazy loading
$moduleAutoloader = new ModuleAutoloader($app->moduleDir());
$moduleAutoloader->register();
$app->run();
$server->runHttp();

View File

@@ -1,20 +1,73 @@
<template>
<RouterView></RouterView>
<SharedSnackbar />
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router';
import { onMounted } from 'vue';
import { onMounted, watch } from 'vue';
import { useTheme } from 'vuetify';
import SharedSnackbar from '@KTXC/components/shared/SharedSnackbar.vue';
import { useLayoutStore } from '@KTXC/stores/layoutStore';
import { useUserStore } from '@KTXC/stores/userStore';
import { useTenantStore } from '@KTXC/stores/tenantStore';
const theme = useTheme();
const layoutStore = useLayoutStore();
const userStore = useUserStore();
const tenantStore = useTenantStore();
// Maps user/tenant setting keys → Vuetify color token names
const COLOR_SETTINGS: Array<{ token: string; key: string }> = [
{ token: 'primary', key: 'primary_color' },
{ token: 'secondary', key: 'secondary_color' },
];
/**
* Apply brand color overrides from stored preferences to all Vuetify theme
* variants (light & dark). Tenant colors take priority when lock is active.
*/
function applyThemeColors(): void {
const locked = tenantStore.getSetting('lock_user_colors') as boolean | null;
for (const { token, key } of COLOR_SETTINGS) {
const value = locked
? ((tenantStore.getSetting(key) as string | null) ?? (userStore.getSetting(key) as string | null))
: ((userStore.getSetting(key) as string | null) ?? (tenantStore.getSetting(key) as string | null));
if (value) {
for (const variant of Object.keys(theme.themes.value)) {
theme.themes.value[variant].colors[token] = value;
}
}
}
}
/** Apply font preference via CSS custom property and body style. */
function applyFont(): void {
const font =
((userStore.getSetting('font') as string | null) ??
(tenantStore.getSetting('font') as string | null));
if (font && font !== 'Public Sans') {
document.documentElement.style.setProperty('--themer-font', font);
document.body.style.fontFamily = `"${font}", sans-serif`;
}
}
// Apply saved theme on mount
onMounted(() => {
// Apply saved theme mode
if (layoutStore.theme) {
theme.global.name.value = layoutStore.theme;
}
applyThemeColors();
applyFont();
});
// Re-apply whenever tenant settings change (e.g. admin saves new brand colors)
watch(() => tenantStore.settings, () => {
applyThemeColors();
applyFont();
}, { deep: true });
</script>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { useSnackbar } from '@KTXC/composables/useSnackbar'
const {
snackbarVisible,
snackbarMessage,
snackbarColor,
snackbarTimeout,
hideSnackbar,
} = useSnackbar()
</script>
<template>
<v-snackbar
:model-value="snackbarVisible"
:color="snackbarColor"
:timeout="snackbarTimeout"
location="bottom right"
@update:model-value="value => !value && hideSnackbar()"
>
{{ snackbarMessage }}
<template #actions>
<v-btn variant="text" @click="hideSnackbar()">
Close
</v-btn>
</template>
</v-snackbar>
</template>

View File

@@ -2,7 +2,7 @@
* Simple snackbar/toast notification composable
* Uses Vuetify's snackbar component
*/
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
export interface SnackbarOptions {
message: string
@@ -10,21 +10,60 @@ export interface SnackbarOptions {
timeout?: number
}
interface SnackbarState {
message: string
color: string
timeout: number
}
const snackbarVisible = ref(false)
const snackbarMessage = ref('')
const snackbarColor = ref<string>('info')
const snackbarTimeout = ref(3000)
const snackbarQueue = ref<SnackbarState[]>([])
function showNextSnackbar() {
if (snackbarVisible.value || snackbarQueue.value.length === 0) {
return
}
const nextSnackbar = snackbarQueue.value.shift()
if (!nextSnackbar) {
return
}
snackbarMessage.value = nextSnackbar.message
snackbarColor.value = nextSnackbar.color
snackbarTimeout.value = nextSnackbar.timeout
snackbarVisible.value = true
}
export function useSnackbar() {
const showSnackbar = (options: SnackbarOptions) => {
snackbarMessage.value = options.message
snackbarColor.value = options.color || 'info'
snackbarTimeout.value = options.timeout || 3000
snackbarVisible.value = true
const notification = {
message: options.message,
color: options.color || 'info',
timeout: options.timeout || 3000,
}
if (!snackbarVisible.value && snackbarQueue.value.length === 0) {
snackbarMessage.value = notification.message
snackbarColor.value = notification.color
snackbarTimeout.value = notification.timeout
snackbarVisible.value = true
return
}
snackbarQueue.value = [...snackbarQueue.value, notification]
}
const hideSnackbar = () => {
snackbarVisible.value = false
void nextTick(() => {
showNextSnackbar()
})
}
return {
@@ -32,6 +71,7 @@ export function useSnackbar() {
snackbarMessage,
snackbarColor,
snackbarTimeout,
snackbarQueue,
showSnackbar,
hideSnackbar,
}

View File

@@ -1,15 +0,0 @@
export type ConfigProps = {
Sidebar_drawer: boolean;
mini_sidebar: boolean;
actTheme: string;
fontTheme: string;
};
const config: ConfigProps = {
Sidebar_drawer: true,
mini_sidebar: false,
actTheme: 'light',
fontTheme: 'Public sans'
};
export default config;

View File

@@ -9,7 +9,7 @@
"vue": "/vendor/vue.mjs",
"vue-router": "/vendor/vue-router.mjs",
"pinia": "/vendor/pinia.mjs",
"@KTXC/utils/helpers/fetch-wrapper-core": "/js/shared-utils.js"
"@KTXC": "/js/ktxc.mjs"
}
}
</script>

View File

@@ -9,15 +9,12 @@ import { useTenantStore } from '@KTXC/stores/tenantStore'
import { useUserStore } from '@KTXC/stores/userStore'
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'
import { initializeModules } from '@KTXC/utils/modules'
import { createSessionMonitor } from '@KTXC/services/authManager'
import App from './App.vue'
import router from './router'
import vuetify from './plugins/vuetify/index'
import '@KTXC/scss/style.scss'
// Material Design Icons (Vuetify mdi icon set)
import '@mdi/font/css/materialdesignicons.min.css'
// google-fonts
import '@fontsource/public-sans/index.css'
const app = createApp(App)
@@ -26,8 +23,6 @@ app.use(pinia)
app.use(PerfectScrollbarPlugin)
app.use(vuetify)
// Note: Router is registered AFTER modules are loaded to prevent premature route matching
const globalWindow = window as typeof window & {
[key: string]: unknown
}
@@ -37,7 +32,6 @@ globalWindow.vue = Vue
globalWindow.VueRouter = VueRouterLib
globalWindow.Pinia = PiniaLib as unknown
// Bootstrap initial private UI state (modules, tenant, user) before mounting
(async () => {
const moduleStore = useModuleStore();
const tenantStore = useTenantStore();
@@ -48,23 +42,23 @@ globalWindow.Pinia = PiniaLib as unknown
moduleStore.init(payload?.modules ?? {});
tenantStore.init(payload?.tenant ?? null);
userStore.init(payload?.user ?? {});
// Initialize auth session monitor
const sessionMonitor = createSessionMonitor({ onLogout: () => userStore.logout() });
sessionMonitor.start();
// Initialize registered modules (following reference app's bootstrap pattern)
// Initialize registered modules
await initializeModules(app);
// Add 404 catch-all route AFTER all modules are loaded
// This ensures module routes are registered before the catch-all
// Register a catch-all and router
router.addRoute({
name: 'NotFound',
path: '/:pathMatch(.*)*',
component: () => import('@KTXC/views/pages/maintenance/error/Error404Page.vue')
});
// Register router AFTER modules are loaded
app.use(router);
await router.isReady();
// Home redirect handled by router beforeEnter
app.mount('#app');
} catch (e) {
console.error('Bootstrap failed:', e);

View File

@@ -3,16 +3,10 @@ import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router';
import vuetify from './plugins/vuetify/index';
import '@KTXC/scss/style.scss';
// Material Design Icons (Vuetify mdi icon set)
import '@mdi/font/css/materialdesignicons.min.css';
// google-fonts
import '@fontsource/public-sans/400.css';
import '@fontsource/public-sans/500.css';
import '@fontsource/public-sans/600.css';
import '@fontsource/public-sans/700.css';
import '@fontsource/public-sans/index.css'
// The public app is served when the user has no valid server session.
// Clear any stale identity data from localStorage to ensure the client

View File

@@ -1,115 +0,0 @@
html {
.bg-success,
.bg-info,
.bg-warning {
color: white !important;
}
}
.v-row + .v-row {
margin-top: 0px;
}
.v-divider {
opacity: 1;
border-color: rgb(var(--v-theme-borderLight));
}
.v-table > .v-table__wrapper > table > thead > tr > th {
color: inherit;
}
.border-blue-right {
border-right: 1px solid rgba(var(--v-theme-borderLight), 0.36);
}
.link-hover {
text-decoration: unset;
&:hover {
text-decoration: underline;
}
}
.v-selection-control {
flex: unset;
}
.customizer-btn .icon {
animation: progress-circular-rotate 1.4s linear infinite;
transform-origin: center center;
transition: all 0.2s ease-in-out;
}
.no-spacer {
.v-list-item__spacer {
display: none !important;
}
}
@keyframes progress-circular-rotate {
100% {
transform: rotate(270deg);
}
}
header {
&.v-toolbar--border {
border-color: rgb(var(--v-theme-borderLight));
}
}
.v-toolbar {
&.v-app-bar {
border-bottom: 1px solid rgba(var(--v-theme-borderLight), 0.8);
}
}
.v-sheet--border {
border: 1px solid rgba(var(--v-theme-borderLight), 0.8);
}
// table css
.v-table {
&.v-table--hover {
> .v-table__wrapper {
> table {
> tbody {
> tr {
&:hover {
td {
background: rgb(var(--v-theme-gray100));
}
}
}
}
}
}
}
}
// accordion page css
.v-expansion-panel {
border: 1px solid rgb(var(--v-theme-borderLight));
&:not(:first-child) {
margin-top: -1px;
}
.v-expansion-panel-text__wrapper {
border-top: 1px solid rgb(var(--v-theme-borderLight));
padding: 16px 24px;
}
&.v-expansion-panel--active {
.v-expansion-panel-title--active {
.v-expansion-panel-title__overlay {
background-color: rgb(var(--v-theme-gray100));
}
}
}
}
.v-expansion-panel--active {
> .v-expansion-panel-title {
min-height: unset;
}
}
.v-expansion-panel--disabled .v-expansion-panel-title {
color: rgba(var(--v-theme-on-surface), 0.15);
}

View File

@@ -1,140 +0,0 @@
@use 'sass:math';
@use 'sass:map';
@use 'sass:meta';
@use 'vuetify/lib/styles/tools/functions' as *;
// This will false all colors which is not necessory for theme
$color-pack: false;
// Global font size and border radius
$font-size-root: 1rem;
$border-radius-root: 4px;
$body-font-family: 'Public sans', sans-serif !default;
$heading-font-family: $body-font-family !default;
$btn-font-weight: 400 !default;
$btn-letter-spacing: 0 !default;
// Global Radius as per breakeven point
$rounded: () !default;
$rounded: map-deep-merge(
(
0: 0,
'sm': $border-radius-root * 0.5,
null: $border-radius-root,
'md': $border-radius-root * 1,
'lg': $border-radius-root * 2,
'xl': $border-radius-root * 6,
'pill': 9999px,
'circle': 50%,
'shaped': $border-radius-root * 6 0
),
$rounded
);
// Global Typography
$typography: () !default;
$typography: map-deep-merge(
(
'h1': (
'size': 2.375rem,
'weight': 600,
'line-height': 1.21,
'font-family': inherit
),
'h2': (
'size': 1.875rem,
'weight': 600,
'line-height': 1.27,
'font-family': inherit
),
'h3': (
'size': 1.5rem,
'weight': 600,
'line-height': 1.33,
'font-family': inherit
),
'h4': (
'size': 1.25rem,
'weight': 600,
'line-height': 1.4,
'font-family': inherit
),
'h5': (
'size': 1rem,
'weight': 600,
'line-height': 1.5,
'font-family': inherit
),
'h6': (
'size': 0.875rem,
'weight': 400,
'line-height': 1.57,
'font-family': inherit
),
'subtitle-1': (
'size': 0.875rem,
'weight': 600,
'line-height': 1.57,
'font-family': inherit
),
'subtitle-2': (
'size': 0.75rem,
'weight': 500,
'line-height': 1.66,
'font-family': inherit
),
'body-1': (
'size': 0.875rem,
'weight': 400,
'line-height': 1.57,
'font-family': inherit
),
'body-2': (
'size': 0.75rem,
'weight': 400,
'line-height': 1.66,
'font-family': inherit
),
'button': (
'size': 0.875rem,
'weight': 500,
'font-family': inherit,
'text-transform': uppercase
),
'caption': (
'size': 0.75rem,
'weight': 400,
'letter-spacing': 0,
'font-family': inherit
),
'overline': (
'size': 0.75rem,
'weight': 500,
'font-family': inherit,
'line-height': 1.67,
'letter-spacing': 0,
'text-transform': uppercase
)
),
$typography
);
// Custom Variables
// colors
$white: #fff !default;
// cards
$card-item-spacer-xy: 20px !default;
$card-text-spacer: 20px !default;
$card-title-size: 16px !default;
// Global Shadow
$box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.08);
$theme-colors: (
primary: var(--v-theme-primary),
secondary: var(--v-theme-secondary),
success: var(--v-theme-success),
info: var(--v-theme-info),
warning: var(--v-theme-warning),
error: var(--v-theme-error)
);

View File

@@ -1,37 +0,0 @@
.single-line-alert {
.v-alert__close,
.v-alert__prepend {
align-self: center !important;
}
}
.v-alert__prepend {
align-self: center;
}
.v-alert--variant-tonal {
&.with-border {
@each $color, $value in $theme-colors {
&.text-#{$color} {
border: 1px solid rgba(#{$value}, 0.3);
}
}
}
}
@media (max-width: 500px) {
.single-line-alert {
display: flex;
flex-wrap: wrap;
.v-alert__append {
margin-inline-start: 0px;
}
.v-alert__close {
margin-left: auto;
}
.v-alert__content {
width: 100%;
margin-top: 5px;
}
}
}

View File

@@ -1,11 +0,0 @@
.v-badge__badge {
min-width: 16px;
height: 16px;
padding: 4px;
}
.v-badge--dot {
.v-badge__badge {
height: 8px;
width: 8px;
}
}

View File

@@ -1,32 +0,0 @@
.v-breadcrumbs-item--link {
color: rgb(var(--v-theme-lightText));
}
.v-breadcrumbs {
.v-breadcrumbs-item--disabled {
--v-disabled-opacity: 1;
.v-breadcrumbs-item--link {
color: rgb(var(--v-theme-darkText));
}
}
.v-breadcrumbs-divider {
color: rgb(var(--v-theme-lightText));
}
}
.breadcrumb-with-title {
.v-toolbar__content {
height: unset !important;
padding: 20px 0;
}
.v-breadcrumbs__prepend {
svg {
vertical-align: -3px;
}
}
}
.breadcrumb-height {
.v-toolbar__content {
height: unset !important;
}
}

View File

@@ -1,68 +0,0 @@
//
// Light Buttons
//
.v-btn {
&.bg-lightprimary {
&:hover,
&:active,
&:focus {
background-color: rgb(var(--v-theme-primary)) !important;
color: $white !important;
}
}
&.bg-lightsecondary {
&:hover,
&:active,
&:focus {
background-color: rgb(var(--v-theme-secondary)) !important;
color: $white !important;
}
}
&.text-facebook {
&:hover,
&:active,
&:focus {
background-color: rgb(var(--v-theme-facebook)) !important;
color: $white !important;
}
}
&.text-twitter {
&:hover,
&:active,
&:focus {
background-color: rgb(var(--v-theme-twitter)) !important;
color: $white !important;
}
}
&.text-linkedin {
&:hover,
&:active,
&:focus {
background-color: rgb(var(--v-theme-linkedin)) !important;
color: $white !important;
}
}
}
.v-btn {
text-transform: capitalize;
letter-spacing: $btn-letter-spacing;
font-weight: 400;
}
.v-btn--icon.v-btn--density-default {
width: calc(var(--v-btn-height) + 6px);
height: calc(var(--v-btn-height) + 6px);
}
.v-btn-group .v-btn {
height: inherit !important;
}
.v-btn-group {
border-color: rgba(var(--v-border-color), 1);
}
.v-btn-group--divided .v-btn:not(:last-child) {
border-inline-end-color: rgba(var(--v-border-color), 1);
}

View File

@@ -1,40 +0,0 @@
// Outline Card
.v-card--variant-outlined {
border-color: rgba(var(--v-theme-borderLight), 1);
.v-divider {
border-color: rgba(var(--v-theme-borderLight), 0.8);
}
}
.v-card-text {
padding: $card-text-spacer;
}
.v-card-actions {
padding: 14px $card-text-spacer 14px;
}
.v-card {
overflow: visible;
.v-card-title {
&.text-h6 {
font-weight: 600;
line-height: 1.57;
}
}
}
.v-card-item {
padding: $card-item-spacer-xy;
}
.v-card-subtitle {
font-size: 0.75rem;
}
.title-card {
.v-card-text {
background-color: rgb(var(--v-theme-background));
border: 1px solid rgba(var(--v-theme-borderLight), 1);
}
}

View File

@@ -1,9 +0,0 @@
.v-field--variant-outlined .v-field__outline__start.v-locale--is-ltr,
.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__start {
border-radius: $border-radius-root 0 0 $border-radius-root;
}
.v-field--variant-outlined .v-field__outline__end.v-locale--is-ltr,
.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__end {
border-radius: 0 $border-radius-root $border-radius-root 0;
}

View File

@@ -1,55 +0,0 @@
.v-input--density-default:not(.v-autocomplete--multiple),
.v-field--variant-solo,
.v-field--variant-filled {
--v-input-control-height: 39px;
--v-input-padding-top: 2px;
input.v-field__input {
padding-bottom: 2px;
}
.v-field__input {
padding-bottom: 2px;
}
textarea {
padding-top: 11px;
}
}
.v-input--density-default {
.v-field__input {
min-height: 41px;
}
}
.v-field--variant-outlined {
&.v-field--focused {
.v-field__outline {
--v-field-border-width: 1px;
}
}
}
.v-input {
.v-input__details {
padding-inline: 0;
}
}
.v-input--density-comfortable {
--v-input-control-height: 56px;
--v-input-padding-top: 17px;
}
.v-label {
font-size: 0.875rem;
--v-medium-emphasis-opacity: 0.8;
}
.v-switch .v-label,
.v-checkbox .v-label {
opacity: 1;
}
textarea.v-field__input {
font-size: 14px;
}
.textarea-input {
.v-label {
top: 15px;
}
}

View File

@@ -1,47 +0,0 @@
.v-list-item {
&.v-list-item--border {
border-color: rgb(var(--v-border-color));
border-width: 0 0 1px 0;
&:last-child {
border-width: 0;
}
}
&.v-list-item--variant-tonal {
background: rgb(var(--v-theme-gray100));
.v-list-item__underlay {
background: transparent;
}
}
&:last-child {
.v-list-item__content {
.v-divider--inset {
display: none;
}
}
}
}
.v-list {
&[aria-busy='true'] {
cursor: context-menu;
}
}
.v-list-group__items {
.v-list-item {
padding-inline-start: 40px !important;
}
}
.v-list-item__content {
.v-divider--inset:not(.v-divider--vertical) {
max-width: 100%;
margin-inline-start: 0;
}
}
.v-list--border {
.v-list-item {
+ .v-list-item {
border-top: 1px solid rgb(var(--v-theme-borderLight));
}
}
}

View File

@@ -1,3 +0,0 @@
.v-navigation-drawer__scrim.fade-transition-leave-to {
display: none;
}

View File

@@ -1,20 +0,0 @@
.elevation-24 {
box-shadow: $box-shadow !important;
}
.v-menu {
> .v-overlay__content {
> .v-sheet {
box-shadow: $box-shadow;
}
}
}
@each $color, $value in $theme-colors {
.#{$color}-shadow {
box-shadow: 0 14px 12px rgba(#{$value}, 0.2);
&:hover {
box-shadow: none;
}
}
}

View File

@@ -1,18 +0,0 @@
.v-text-field input {
font-size: 0.875rem;
}
.v-field__outline {
color: rgb(var(--v-theme-inputBorder));
}
.inputWithbg {
.v-field--variant-outlined {
background-color: rgba(0, 0, 0, 0.025);
}
}
.v-select {
.v-field {
font-size: 0.875rem;
}
}

View File

@@ -1,7 +0,0 @@
.v-textarea input {
font-size: 0.875rem;
font-weight: 500;
&::placeholder {
color: rgba(0, 0, 0, 0.38);
}
}

View File

@@ -1,146 +0,0 @@
html {
overflow-y: auto;
}
.horizontalLayout {
.page-wrapper {
.v-container {
padding-top: 20px;
}
}
}
.spacer {
padding: 100px 0;
@media (max-width: 1264px) {
padding: 72px 0;
}
}
@media (max-width: 800px) {
.spacer {
padding: 40px 0;
}
}
.cursor-pointer {
cursor: pointer;
}
.page-wrapper {
background: rgb(var(--v-theme-containerBg));
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
overflow: hidden;
.page-content-container {
flex: 1;
overflow: hidden;
min-height: 0;
display: flex;
flex-direction: column;
padding: 15px;
@media (max-width: 1550px) {
max-width: 100%;
}
@media (min-width: 768px) {
padding-inline: 40px;
}
}
.page-footer-container {
flex-shrink: 0;
}
.v-container {
padding: 15px;
@media (max-width: 1550px) {
max-width: 100%;
}
@media (min-width: 768px) {
padding-inline: 40px;
}
}
}
.maxWidth {
max-width: 1200px;
margin: 0 auto;
}
$sizes: (
'display-1': 44px,
'display-2': 40px,
'display-3': 30px,
'h1': 36px,
'h2': 30px,
'h3': 21px,
'h4': 18px,
'h5': 16px,
'h6': 14px,
'text-8': 8px,
'text-10': 10px,
'text-13': 13px,
'text-18': 18px,
'text-20': 20px,
'text-24': 24px,
'body-text-1': 10px
);
@each $pixel, $size in $sizes {
.#{$pixel} {
font-size: $size;
line-height: $size + 10;
}
}
.customizer-btn {
.icon {
animation: progress-circular-rotate 1.4s linear infinite;
transform-origin: center center;
transition: all 0.2s ease-in-out;
}
}
.fixed-width {
max-width: 1300px;
}
.ga-2 {
gap: 8px;
}
// font family
body {
font-family: 'Public Sans', sans-serif;
.Roboto {
font-family: 'Roboto', sans-serif !important;
}
.Poppins {
font-family: 'Poppins', sans-serif !important;
}
.Inter {
font-family: 'Inter', sans-serif !important;
}
.Public {
font-family: 'Public sans', sans-serif !important;
}
}
@keyframes slideY {
0%,
50%,
100% {
transform: translateY(0px);
}
25% {
transform: translateY(-10px);
}
75% {
transform: translateY(10px);
}
}
.link {
color: rgb(var(--v-theme-lightText));
text-decoration: none;
&:hover {
color: rgb(var(--v-theme-primary));
}
}

View File

@@ -1,25 +0,0 @@
.v-footer {
background: rgb(var(--v-theme-containerbg));
padding: 24px 16px 0px;
margin-top: auto;
position: unset;
a {
text-decoration: unset;
&:hover {
text-decoration: underline;
}
}
}
@media (max-width: 475px) {
.footer {
text-align: center;
.v-col-6 {
flex: 0 0 100%;
max-width: 100%;
&.text-right {
text-align: center !important;
}
}
}
}

View File

@@ -1,165 +0,0 @@
/*This is for the logo*/
.leftSidebar {
border: 0px;
box-shadow: none !important;
border-right: 1px solid rgba(var(--v-theme-borderLight), 0.8);
.logo {
padding-left: 7px;
}
}
/*This is for the Vertical sidebar*/
.scrollnavbar {
height: calc(100vh - 110px);
.smallCap {
padding: 0px 0 0 20px !important;
}
.v-list {
color: rgb(var(--v-theme-lightText));
padding: 0;
.v-list-item--one-line {
&.v-list-item--active {
border-right: 2px solid rgb(var(--v-theme-primary));
}
}
.v-list-group {
.v-list-item--one-line {
&.v-list-item--active.v-list-item--link {
border-right: 2px solid rgb(var(--v-theme-primary));
}
&.v-list-item--active.v-list-group__header {
border-right: none;
background: transparent;
}
}
.v-list-group__items {
.v-list-item--link,
.v-list-item {
.v-list-item__prepend {
margin-inline-end: 1px;
}
}
}
}
.v-list-item--variant-plain,
.v-list-item--variant-outlined,
.v-list-item--variant-text,
.v-list-item--variant-tonal {
color: rgb(var(--v-theme-darkText));
}
}
/*General Menu css*/
.v-list-group__items .v-list-item,
.v-list-item {
border-radius: 0;
padding-inline-start: calc(20px + var(--indent-padding) / 2) !important;
.v-list-item__prepend {
margin-inline-end: 13px;
}
.v-list-item__append {
font-size: 0.875rem;
.v-icon {
margin-inline-start: 13px;
}
> .v-icon {
--v-medium-emphasis-opacity: 0.8;
}
}
.v-list-item-title {
font-size: 0.875rem;
color: rgb(var(--v-theme-darkText));
}
&.v-list-item--active {
.v-list-item-title {
color: rgb(var(--v-theme-primary));
}
}
}
/*This is for the dropdown*/
.v-list {
.v-list-item--active {
.v-list-item-title {
font-weight: 500;
}
}
.sidebarchip .v-icon {
margin-inline-start: -3px;
}
.v-list-group {
.v-list-item:focus-visible > .v-list-item__overlay {
opacity: 0;
}
}
> .v-list-group {
position: relative;
> .v-list-item--active,
> .v-list-item:hover {
background: rgb(var(--v-theme-primary), 0.05);
}
}
}
}
.v-navigation-drawer--rail {
.scrollnavbar .v-list .v-list-group__items,
.hide-menu {
opacity: 0;
}
.scrollnavbar {
.v-list-item {
.v-list-item__prepend {
margin-left: 8px;
.anticon {
svg {
width: 20px;
height: 20px;
}
}
}
}
.v-list-group__items .v-list-item,
.v-list-item {
padding-inline-start: calc(12px + var(--indent-padding) / 2) !important;
}
.ExtraBox {
display: none;
}
}
.sidebar-user {
margin-left: -6px;
}
.leftPadding {
margin-left: 0px;
}
&.leftSidebar {
.v-list-subheader {
display: none;
}
.v-navigation-drawer__content {
.pa-5 {
padding-left: 10px !important;
.logo {
padding-left: 0;
}
}
}
}
}
@media only screen and (min-width: 1170px) {
.mini-sidebar {
.logo {
width: 40px;
overflow: hidden;
}
.leftSidebar:hover {
box-shadow: $box-shadow !important;
}
.v-navigation-drawer--expand-on-hover:hover {
.logo {
width: 100%;
}
.v-list .v-list-group__items,
.hide-menu {
opacity: 1;
}
}
}
}

View File

@@ -1,41 +0,0 @@
.profileBtn {
height: 44px !important;
margin: 0 20px 0 10px !important;
padding: 0 6px;
.v-avatar {
width: 32px;
height: 32px;
img {
width: 32px;
height: 32px;
}
}
}
@media (max-width: 600px) {
.profileBtn {
min-width: 42px;
margin: 0 12px 0 0 !important;
.v-avatar {
width: 24px;
height: 24px;
img {
width: 24px;
height: 24px;
}
}
}
}
@media (max-width: 460px) {
.notification-dropdown {
width: 332px !important;
.v-list-item__content {
.d-inline-flex {
.text-caption {
min-width: 50px;
}
}
}
}
}

View File

@@ -1,9 +0,0 @@
@import './variables';
@import 'vuetify/styles/main.sass';
@import './override';
@import './layout/container';
@import './layout/sidebar';
@import './layout/footer';
@import './layout/topbar';
@import 'vue3-perfect-scrollbar/style.css';

View File

@@ -0,0 +1,82 @@
/**
* Authentication Manager
*/
const REFRESH_URL = '/auth/refresh';
const PING_URL = '/auth/ping';
const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
const PING_INTERVAL_MS = 60 * 1000;
let tokenExpiresAt: number | null = null;
let refreshPromise: Promise<boolean> | null = null;
export function recordExpiry(expiresIn: number | null | undefined): void {
tokenExpiresAt = expiresIn != null ? Date.now() + expiresIn * 1000 : null;
}
function isTokenExpiredOrExpiring(): boolean {
if (tokenExpiresAt === null) return false;
return Date.now() >= tokenExpiresAt - TOKEN_EXPIRY_BUFFER_MS;
}
async function refreshToken(): Promise<boolean> {
if (refreshPromise) return refreshPromise;
refreshPromise = fetch(REFRESH_URL, { method: 'POST', credentials: 'include' })
.then(async r => {
if (!r.ok) return false;
try {
const body = await r.clone().json();
recordExpiry(body?.expires_in);
} catch { /* ignore parse errors */ }
return true;
})
.catch(() => false)
.finally(() => { refreshPromise = null; });
return refreshPromise;
}
/**
* Ensures the token is fresh before a request fires.
*/
export async function ensureFreshToken(): Promise<void> {
if (isTokenExpiredOrExpiring()) {
await refreshToken();
}
}
export interface SessionMonitorOptions {
onLogout: () => void | Promise<void>;
}
export function createSessionMonitor(options: SessionMonitorOptions) {
let intervalId: ReturnType<typeof setInterval> | null = null;
async function ping(): Promise<void> {
// GET ping — no body, no CSRF needed, just confirm the session cookie is still valid
const response = await fetch(PING_URL, { credentials: 'include' }).catch(() => null);
if (!response?.ok) {
// Session may be expired — attempt a token refresh before giving up
const refreshed = await refreshToken();
if (!refreshed) {
stop();
await options.onLogout();
}
}
}
function start(): void {
if (intervalId !== null) return;
intervalId = setInterval(ping, PING_INTERVAL_MS);
}
function stop(): void {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
}
return { start, stop };
}

View File

@@ -6,7 +6,7 @@ export const authenticationService = {
* Initialize authentication - get session and available methods
*/
async start(): Promise<StartResponse> {
return fetchWrapper.get('/auth/start', undefined, { skipLogoutOnError: true });
return fetchWrapper.get('/auth/start');
},
/**
@@ -20,7 +20,7 @@ export const authenticationService = {
return fetchWrapper.post('/auth/identify', {
session,
identity,
}, { skipLogoutOnError: true });
});
},
/**
@@ -42,7 +42,7 @@ export const authenticationService = {
method,
response,
...(identity && { identity }),
}, { autoRetry: false, skipLogoutOnError: true });
});
},
/**
@@ -57,7 +57,7 @@ export const authenticationService = {
session,
method,
return_url: returnUrl,
}, { skipLogoutOnError: true });
});
},
/**
@@ -67,14 +67,14 @@ export const authenticationService = {
return fetchWrapper.post('/auth/challenge', {
session,
method,
}, { skipLogoutOnError: true });
});
},
/**
* Get current session status
*/
async getStatus(session: string): Promise<SessionStatus> {
return fetchWrapper.get(`/auth/status?session=${encodeURIComponent(session)}`, undefined, { skipLogoutOnError: true });
return fetchWrapper.get(`/auth/status?session=${encodeURIComponent(session)}`);
},
/**

29
core/src/shared/index.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* KTXC Host SDK
*
* This is the single public contract between the host application and modules.
* Modules must import from '@KTXC' (this file), never from '@KTXC/...' sub-paths.
* At runtime, '@KTXC' resolves to '/js/ktxc.mjs' via the import map.
*/
// Stores
export { useModuleStore } from '../stores/moduleStore'
export { useTenantStore } from '../stores/tenantStore'
export { useUserStore } from '../stores/userStore'
export { useIntegrationStore } from '../stores/integrationStore'
export { useLayoutStore } from '../stores/layoutStore'
// Composables
export { useUser } from '../composables/useUser'
export { useClipboard } from '../composables/useClipboard'
export { useSnackbar } from '../composables/useSnackbar'
// Services
export { userService } from '../services/user/userService'
// Utilities
export { fetchWrapper } from '../utils/helpers/fetch-wrapper'
export { createFetchWrapper } from '../utils/helpers/fetch-wrapper-core'
// Types
export type { ModuleIntegrations } from '../types/moduleTypes'

View File

@@ -1,6 +1,5 @@
import { ref, watch } from 'vue';
import { defineStore } from 'pinia';
import config from '@KTXC/config';
import { useUserStore } from './userStore';
export type MenuMode = 'apps' | 'user-settings' | 'admin-settings';
@@ -9,15 +8,15 @@ export const useLayoutStore = defineStore('layout', () => {
// Loading state
const isLoading = ref(false);
// Sidebar state - initialize from settings or config
// Sidebar state - initialize from settings or defaults
const userStore = useUserStore();
const sidebarDrawer = ref(userStore.getSetting('sidebar_drawer') ?? config.Sidebar_drawer);
const miniSidebar = ref(userStore.getSetting('mini_sidebar') ?? config.mini_sidebar);
const sidebarDrawer = ref(userStore.getSetting('sidebar_drawer') ?? true);
const miniSidebar = ref(userStore.getSetting('mini_sidebar') ?? false);
const menuMode = ref<MenuMode>('apps');
// Theme state - initialize from settings or config
const theme = ref(userStore.getSetting('theme') ?? config.actTheme);
const font = ref(userStore.getSetting('font') ?? config.fontTheme);
// Theme state - initialize from settings or defaults
const theme = ref(userStore.getSetting('theme') ?? 'light');
const font = ref(userStore.getSetting('font') ?? 'Public sans');
// Watch and sync sidebar state to settings
watch(sidebarDrawer, (value) => {

View File

@@ -1,4 +1,6 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper';
export interface TenantState {
id: string | null;
@@ -6,22 +8,117 @@ export interface TenantState {
label: string | null;
}
export const useTenantStore = defineStore('tenantStore', {
state: () => ({
tenant: null as TenantState | null,
}),
actions: {
init(tenant: Partial<TenantState> | null) {
this.tenant = tenant
? {
id: tenant.id ?? null,
domain: tenant.domain ?? null,
label: tenant.label ?? null,
}
: null;
},
reset() {
this.tenant = null;
},
},
export interface TenantData extends TenantState {
settings?: Record<string, unknown>;
}
// Flush pending settings writes before the page unloads
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
useTenantStore().flushSettings();
});
}
export const useTenantStore = defineStore('tenantStore', () => {
// =========================================================================
// State
// =========================================================================
const tenant = ref<TenantState | null>(null);
const settings = ref<Record<string, unknown>>({});
// Pending batch for debounced writes
let _pendingSettings: Record<string, unknown> = {};
let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
// =========================================================================
// Getters
// =========================================================================
function getSetting(key: string): unknown {
return settings.value[key] ?? null;
}
// =========================================================================
// Settings actions
// =========================================================================
function setSetting(key: string, value: unknown): void {
settings.value[key] = value;
// Batch writes with a 500 ms debounce — same pattern as userService
_pendingSettings[key] = value;
if (_debounceTimer !== null) {
clearTimeout(_debounceTimer);
}
_debounceTimer = setTimeout(() => {
_flush();
}, 500);
}
async function _flush(): Promise<void> {
if (Object.keys(_pendingSettings).length === 0) return;
const payload = { ..._pendingSettings };
_pendingSettings = {};
_debounceTimer = null;
try {
await fetchWrapper.patch('/tenant/settings', { data: payload });
} catch (error) {
console.error('Failed to save tenant settings:', error);
}
}
/** Force-flush any pending settings writes (called on beforeunload). */
async function flushSettings(): Promise<void> {
if (_debounceTimer !== null) {
clearTimeout(_debounceTimer);
_debounceTimer = null;
}
await _flush();
}
// =========================================================================
// Initialise from /init endpoint
// =========================================================================
function init(tenantData: Partial<TenantData> | null): void {
tenant.value = tenantData
? {
id: tenantData.id ?? null,
domain: tenantData.domain ?? null,
label: tenantData.label ?? null,
}
: null;
settings.value = (tenantData?.settings as Record<string, unknown>) ?? {};
}
function reset(): void {
tenant.value = null;
settings.value = {};
_pendingSettings = {};
if (_debounceTimer !== null) {
clearTimeout(_debounceTimer);
_debounceTimer = null;
}
}
return {
// State
tenant,
settings,
// Getters
getSetting,
// Actions
setSetting,
flushSettings,
init,
reset,
};
});

View File

@@ -1,6 +1,5 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { router } from '@KTXC/router';
import { authenticationService } from '@KTXC/services/authenticationService';
import { userService } from '@KTXC/services/user/userService';
import type { AuthenticatedUser } from '@KTXC/types/authenticationTypes';
@@ -80,7 +79,7 @@ export const useUserStore = defineStore('userStore', () => {
clearAuth();
clearProfile();
clearSettings();
router.push('/login');
window.location.href = '/login';
}
}

View File

@@ -1,161 +1,92 @@
/**
* Core fetch wrapper - reusable across modules
* Does not depend on stores to avoid bundling issues in library builds
*/
export interface FetchWrapperOptions {
/**
* Optional callback to handle logout on auth failure
* If not provided, only logs error without redirecting
*/
onLogout?: () => void | Promise<void>;
/**
* Enable automatic retry of failed requests after token refresh
* @default true
*/
autoRetry?: boolean;
/** Additional headers merged into every request */
headers?: Record<string, string>;
/** Override default Content-Type (default: application/json) */
contentType?: string;
/** Override default Accept type (default: application/json) */
accept?: string;
/** Called before every request */
beforeRequest?: () => Promise<void>;
/** Called after every successful non-stream response */
afterResponse?: (data: any) => void;
}
// Mutex to prevent multiple simultaneous refresh attempts
class RefreshMutex {
private promise: Promise<boolean> | null = null;
async acquire(): Promise<boolean> {
if (this.promise) {
return this.promise;
}
this.promise = this.performRefresh();
const result = await this.promise;
this.promise = null;
return result;
}
private async performRefresh(): Promise<boolean> {
try {
const response = await fetch('/security/refresh', {
method: 'POST',
credentials: 'include'
});
return response.ok;
} catch (error) {
console.error('Token refresh failed:', error);
return false;
}
}
}
const tokenRefreshMutex = new RefreshMutex();
export interface RequestCallOptions {
/**
* Override autoRetry for this specific request
* @default true
*/
autoRetry?: boolean;
/**
* Skip calling onLogout callback on 401/403 errors
* Useful for authentication endpoints where 401 means invalid credentials, not session expiry
* @default false
*/
skipLogoutOnError?: boolean;
/** When set, the raw Response is forwarded to this handler instead of being JSON-parsed */
onStream?: (response: Response) => Promise<void>;
/** Per-call header overrides — highest priority, wins over factory-level headers */
headers?: Record<string, string>;
}
function getCsrfToken(): string | null {
if (typeof document === 'undefined') return null;
const cookie = document.cookie.split('; ').find(r => r.startsWith('X-CSRF-TOKEN='));
return cookie ? (cookie.split('=')[1] ?? null) : null;
}
export function createFetchWrapper(options: FetchWrapperOptions = {}) {
const { autoRetry: defaultAutoRetry = true } = options;
return {
get: request('GET', options, defaultAutoRetry),
post: request('POST', options, defaultAutoRetry),
put: request('PUT', options, defaultAutoRetry),
delete: request('DELETE', options, defaultAutoRetry)
};
}
async function request(
method: string,
url: string,
body?: object,
callOptions?: RequestCallOptions
): Promise<any> {
if (options.beforeRequest) {
await options.beforeRequest();
}
interface RequestOptions {
method: string;
headers: Record<string, string>;
body?: string;
credentials: 'include';
}
function request(method: string, options: FetchWrapperOptions, defaultAutoRetry: boolean) {
return async (url: string, body?: object, callOptions?: RequestCallOptions): Promise<any> => {
const autoRetry = callOptions?.autoRetry ?? defaultAutoRetry;
const requestOptions: RequestOptions = {
method,
headers: getHeaders(url),
credentials: 'include'
// Header priority: defaults < factory options.headers < callOptions.headers
const headers: Record<string, string> = {
'Content-Type': options.contentType ?? 'application/json',
'Accept': options.accept ?? 'application/json',
...options.headers,
...callOptions?.headers,
};
if (body) {
requestOptions.headers['Content-Type'] = 'application/json';
requestOptions.body = JSON.stringify(body);
const csrf = getCsrfToken();
if (csrf) headers['X-CSRF-TOKEN'] = csrf;
const requestOptions: RequestInit = {
method,
headers,
credentials: 'include',
body: body ? JSON.stringify(body) : undefined,
};
const response = await fetch(url, requestOptions);
if (!response.ok) {
const text = await response.text();
const data = text ? JSON.parse(text) : null;
throw new Error(data?.message || response.statusText);
}
try {
const response = await fetch(url, requestOptions);
if (response.status === 401 && autoRetry) {
// Try to refresh the token
const refreshSuccess = await tokenRefreshMutex.acquire();
if (refreshSuccess) {
// Retry the original request with the new token
const retryResponse = await fetch(url, requestOptions);
return handleResponse(retryResponse, options, callOptions?.skipLogoutOnError);
}
}
return handleResponse(response, options, callOptions?.skipLogoutOnError);
} catch (error) {
console.error('API error:', error);
throw error;
if (callOptions?.onStream) {
return callOptions.onStream(response);
}
const text = await response.text();
const data = text ? JSON.parse(text) : null;
options.afterResponse?.(data);
return data;
}
return {
get: (url: string, callOptions?: RequestCallOptions) =>
request('GET', url, undefined, callOptions),
post: (url: string, body?: object, callOptions?: RequestCallOptions) =>
request('POST', url, body, callOptions),
put: (url: string, body?: object, callOptions?: RequestCallOptions) =>
request('PUT', url, body, callOptions),
patch: (url: string, body?: object, callOptions?: RequestCallOptions) =>
request('PATCH', url, body, callOptions),
delete: (url: string, callOptions?: RequestCallOptions) =>
request('DELETE', url, undefined, callOptions),
};
}
function getHeaders(_url: string): Record<string, string> {
const headers: Record<string, string> = {};
// Add CSRF token if available
const csrfToken = getCsrfTokenFromCookie();
if (csrfToken) {
headers['X-CSRF-TOKEN'] = csrfToken;
}
return headers;
}
function getCsrfTokenFromCookie(): string | null {
if (typeof document === 'undefined') return null;
const csrfCookie = document.cookie
.split('; ')
.find(row => row.startsWith('X-CSRF-TOKEN='));
return csrfCookie ? csrfCookie.split('=')[1] : null;
}
async function handleResponse(response: Response, options: FetchWrapperOptions, skipLogoutOnError?: boolean): Promise<any> {
const text = await response.text();
const data = text && JSON.parse(text);
if (!response.ok) {
if ([401, 403].includes(response.status) && !skipLogoutOnError) {
// Call logout callback if provided
if (options.onLogout) {
await options.onLogout();
} else {
console.error('Authentication failed. Please log in again.');
}
}
const error: string = (data && data.message) || response.statusText;
throw new Error(error);
}
return data;
}

View File

@@ -1,10 +1,7 @@
import { useUserStore } from '@KTXC/stores/userStore';
import { createFetchWrapper } from './fetch-wrapper-core';
import { ensureFreshToken, recordExpiry } from '@KTXC/services/authManager';
// Create fetch wrapper with user store logout callback
export const fetchWrapper = createFetchWrapper({
onLogout: () => {
const { logout } = useUserStore();
logout();
}
beforeRequest: ensureFreshToken,
afterResponse: (data) => recordExpiry(data?.expires_in),
});

View File

@@ -1,6 +0,0 @@
/**
* Shared utilities entry point for external modules
* This file is built separately and exposed via import map
*/
export { createFetchWrapper, type FetchWrapperOptions } from './fetch-wrapper-core';

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Collection;
use KTXF\Resource\Provider\Node\NodeBaseAbstract;
/**
* Abstract Chrono Collection Base Class
*
* Provides common implementation for chrono collections
*
* @since 2025.05.01
*/
abstract class CollectionBase extends NodeBaseAbstract implements CollectionBaseInterface {
protected CollectionPropertiesBaseAbstract $properties;
/**
* @inheritDoc
*/
public function getProperties(): CollectionPropertiesBaseInterface {
return $this->properties;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Collection;
use KTXF\Resource\Provider\Node\NodeBaseInterface;
/**
* Collection Base Interface
*
* Interface represents a collection in a service
*
* @since 2025.05.01
*/
interface CollectionBaseInterface extends NodeBaseInterface {
/**
* Gets the collection properties
*
* @since 2025.05.01
*/
public function getProperties(): CollectionPropertiesBaseInterface|CollectionPropertiesMutableInterface;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Collection;
use KTXF\Resource\Provider\Node\NodeMutableAbstract;
use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface;
/**
* Abstract Chrono Collection Mutable Class
*
* Provides common implementation for mutable chrono collections
*
* @since 2025.05.01
*/
abstract class CollectionMutableAbstract extends NodeMutableAbstract implements CollectionMutableInterface {
protected CollectionPropertiesMutableAbstract $properties;
/**
* @inheritDoc
*/
public function getProperties(): CollectionPropertiesMutableInterface {
return $this->properties;
}
/**
* @inheritDoc
*/
public function setProperties(NodePropertiesMutableInterface $value): static {
if (!$value instanceof CollectionPropertiesMutableInterface) {
throw new \InvalidArgumentException('Properties must implement CollectionPropertiesMutableInterface');
}
// Copy all property values
$this->properties->setLabel($value->getLabel());
return $this;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Collection;
use KTXF\Resource\Provider\Node\NodeMutableInterface;
/**
* Chrono Collection Mutable Interface
*
* Interface for altering collection properties in a chrono service
*
* @since 2025.05.01
*
* @method static setProperties(CollectionPropertiesMutableInterface $value)
*/
interface CollectionMutableInterface extends CollectionBaseInterface, NodeMutableInterface {
/**
* Gets the collection properties (mutable)
*
* @since 2025.05.01
*/
public function getProperties(): CollectionPropertiesMutableInterface;
}

View File

@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Collection;
use JsonSerializable;
enum CollectionPermissions: string implements JsonSerializable {
case View = 'view';
case Create = 'create';
case Modify = 'modify';
case Destroy = 'destroy';
case Share = 'share';
public function jsonSerialize(): string {
return $this->value;
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Collection;
use KTXF\Resource\Provider\Node\NodePropertiesBaseAbstract;
/**
* Abstract Chrono Collection Properties Base Class
*
* Provides common implementation for chrono collection properties
*
* @since 2025.05.01
*/
abstract class CollectionPropertiesBaseAbstract extends NodePropertiesBaseAbstract implements CollectionPropertiesBaseInterface {
public const JSON_TYPE = CollectionPropertiesBaseInterface::JSON_TYPE;
/**
* @inheritDoc
*/
public function content(): CollectionContent {
$content = $this->data[self::JSON_PROPERTY_CONTENTS] ?? null;
if ($content instanceof CollectionContent) {
return $content;
}
if (is_string($content)) {
return CollectionContent::tryFrom($content) ?? CollectionContent::Event;
}
return CollectionContent::Event;
}
/**
* @inheritDoc
*/
public function getLabel(): string {
return $this->data[self::JSON_PROPERTY_LABEL] ?? '';
}
/**
* @inheritDoc
*/
public function getDescription(): ?string {
return $this->data[self::JSON_PROPERTY_DESCRIPTION] ?? null;
}
/**
* @inheritDoc
*/
public function getPriority(): ?int {
return $this->data[self::JSON_PROPERTY_PRIORITY] ?? null;
}
/**
* @inheritDoc
*/
public function getVisibility(): ?bool {
return $this->data[self::JSON_PROPERTY_VISIBILITY] ?? null;
}
/**
* @inheritDoc
*/
public function getColor(): ?string {
return $this->data[self::JSON_PROPERTY_COLOR] ?? null;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Collection;
use KTXF\Resource\Provider\Node\NodePropertiesBaseInterface;
interface CollectionPropertiesBaseInterface extends NodePropertiesBaseInterface {
public const JSON_TYPE = 'chrono:collection';
public const JSON_PROPERTY_CONTENTS = 'content';
public const JSON_PROPERTY_LABEL = 'label';
public const JSON_PROPERTY_DESCRIPTION = 'description';
public const JSON_PROPERTY_PRIORITY = 'priority';
public const JSON_PROPERTY_VISIBILITY = 'visibility';
public const JSON_PROPERTY_COLOR = 'color';
public function content(): CollectionContent;
/**
* Gets the human friendly name of this collection (e.g. Personal Calendar)
*
* @since 2025.05.01
*/
public function getLabel(): string;
/**
* Gets the human friendly description of this collection
*
* @since 2025.05.01
*/
public function getDescription(): ?string;
/**
* Gets the priority of this collection
*
* @since 2025.05.01
*/
public function getPriority(): ?int;
/**
* Gets the visibility of this collection
*
* @since 2025.05.01
*/
public function getVisibility(): ?bool;
/**
* Gets the color of this collection
*
* @since 2025.05.01
*/
public function getColor(): ?string;
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Collection;
/**
* Abstract Chrono Collection Properties Mutable Class
*/
abstract class CollectionPropertiesMutableAbstract extends CollectionPropertiesBaseAbstract implements CollectionPropertiesMutableInterface {
public const JSON_TYPE = CollectionPropertiesBaseInterface::JSON_TYPE;
/**
* @inheritDoc
*/
public function jsonDeserialize(array|string $data): static {
if (is_string($data)) {
$data = json_decode($data, true);
}
$this->data = $data;
return $this;
}
/**
* @inheritDoc
*/
public function setLabel(string $value): static {
$this->data[self::JSON_PROPERTY_LABEL] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setDescription(?string $value): static {
$this->data[self::JSON_PROPERTY_DESCRIPTION] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setPriority(?int $value): static {
$this->data[self::JSON_PROPERTY_PRIORITY] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setVisibility(?bool $value): static {
$this->data[self::JSON_PROPERTY_VISIBILITY] = $value;
return $this;
}
/**
* @inheritDoc
*/
public function setColor(?string $value): static {
$this->data[self::JSON_PROPERTY_COLOR] = $value;
return $this;
}
}

View File

@@ -9,50 +9,45 @@ declare(strict_types=1);
namespace KTXF\Chrono\Collection;
use KTXF\Json\JsonDeserializable;
use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface;
interface ICollectionMutable extends ICollectionBase, JsonDeserializable {
interface CollectionPropertiesMutableInterface extends CollectionPropertiesBaseInterface, NodePropertiesMutableInterface {
/**
* Sets the active status of this collection
*
* @since 2025.05.01
*/
public function setEnabled(bool $value): self;
public const JSON_TYPE = CollectionPropertiesBaseInterface::JSON_TYPE;
/**
* Sets the human friendly name of this collection (e.g. Personal Calendar)
*
* @since 2025.05.01
*/
public function setLabel(string $value): self;
public function setLabel(string $value): static;
/**
* Sets the human friendly description of this collection
*
* @since 2025.05.01
*/
public function setDescription(?string $value): self;
public function setDescription(?string $value): static;
/**
* Sets the priority of this collection
*
* @since 2025.05.01
*/
public function setPriority(?int $value): self;
public function setPriority(?int $value): static;
/**
* Sets the visibility of this collection
*
* @since 2025.05.01
*/
public function setVisibility(?bool $value): self;
public function setVisibility(?bool $value): static;
/**
* Sets the color of this collection
*
* @since 2025.05.01
*/
public function setColor(?string $value): self;
public function setColor(?string $value): static;
}

View File

@@ -1,160 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Collection;
use DateTimeImmutable;
use JsonSerializable;
interface ICollectionBase extends JsonSerializable {
public const JSON_TYPE = 'chrono.collection';
public const JSON_PROPERTY_TYPE = '@type';
public const JSON_PROPERTY_PROVIDER = 'provider';
public const JSON_PROPERTY_SERVICE = 'service';
public const JSON_PROPERTY_IN = 'in';
public const JSON_PROPERTY_ID = 'id';
public const JSON_PROPERTY_LABEL = 'label';
public const JSON_PROPERTY_DESCRIPTION = 'description';
public const JSON_PROPERTY_PRIORITY = 'priority';
public const JSON_PROPERTY_VISIBILITY = 'visibility';
public const JSON_PROPERTY_COLOR = 'color';
public const JSON_PROPERTY_CREATED = 'created';
public const JSON_PROPERTY_MODIFIED = 'modified';
public const JSON_PROPERTY_ENABLED = 'enabled';
public const JSON_PROPERTY_SIGNATURE = 'signature';
public const JSON_PROPERTY_PERMISSIONS = 'permissions';
public const JSON_PROPERTY_ROLES = 'roles';
public const JSON_PROPERTY_CONTENTS = 'contents';
/**
* Unique identifier of the service this collection belongs to
*
* @since 2025.05.01
*/
public function in(): string|int|null;
/**
* Unique arbitrary text string identifying this collection (e.g. 1 or collection1 or anything else)
*
* @since 2025.05.01
*/
public function id(): string|int;
/**
* Gets the creation date of this collection
*/
public function created(): ?DateTimeImmutable;
/**
* Gets the modification date of this collection
*/
public function modified(): ?DateTimeImmutable;
/**
* Lists all supported attributes
*
* @since 2025.05.01
*
* @return array<string,array<string,bool>>
*/
public function attributes(): array;
/**
* Gets the signature of this collection
*
* @since 2025.05.01
*/
public function signature(): ?string;
/**
* Gets the role(s) of this collection
*
* @since 2025.05.01
*/
public function roles(): array;
/**
* Checks if this collection supports the given role
*
* @since 2025.05.01
*/
public function role(CollectionRoles $value): bool;
/**
* Gets the content types of this collection
*
* @since 2025.05.01
*/
public function contents(): array;
/**
* Checks if this collection contains the given content type
*
* @since 2025.05.01
*/
public function contains(CollectionContent $value): bool;
/**
* Gets the active status of this collection
*
* @since 2025.05.01
*/
public function getEnabled(): bool;
/**
* Gets the permissions of this collection
*
* @since 2025.05.01
*/
public function getPermissions(): array;
/**
* Checks if this collection has the given permission
*
* @since 2025.05.01
*/
public function hasPermission(CollectionPermissions $permission): bool;
/**
* Gets the human friendly name of this collection (e.g. Personal Calendar)
*
* @since 2025.05.01
*/
public function getLabel(): ?string;
/**
* Gets the human friendly description of this collection
*
* @since 2025.05.01
*/
public function getDescription(): ?string;
/**
* Gets the priority of this collection
*
* @since 2025.05.01
*/
public function getPriority(): ?int;
/**
* Gets the visibility of this collection
*
* @since 2025.05.01
*/
public function getVisibility(): ?bool;
/**
* Gets the color of this collection
*
* @since 2025.05.01
*/
public function getColor(): ?string;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Entity;
use KTXF\Resource\Provider\Node\NodeBaseAbstract;
/**
* Abstract Chrono Entity Base Class
*
* Provides common implementation for chrono entities
*
* @since 2025.05.01
*/
abstract class EntityBaseAbstract extends NodeBaseAbstract implements EntityBaseInterface {
protected EntityPropertiesBaseAbstract $properties;
/**
* @inheritDoc
*/
public function getProperties(): EntityPropertiesBaseInterface {
return $this->properties;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Entity;
use KTXF\Resource\Provider\Node\NodeBaseInterface;
interface EntityBaseInterface extends NodeBaseInterface {
public const JSON_TYPE = 'chrono.entity';
/**
* Gets the entity properties
*
* @since 2025.05.01
*/
public function getProperties(): EntityPropertiesBaseInterface|EntityPropertiesMutableInterface;
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Entity;
use KTXF\Resource\Provider\Node\NodeMutableAbstract;
use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface;
/**
* Abstract Chrono Entity Mutable Class
*
* Provides common implementation for mutable chrono entities
*
* @since 2025.05.01
*/
abstract class EntityMutableAbstract extends NodeMutableAbstract implements EntityMutableInterface {
public const JSON_TYPE = EntityMutableInterface::JSON_TYPE;
protected EntityPropertiesMutableAbstract $properties;
/**
* @inheritDoc
*/
public function getProperties(): EntityPropertiesMutableInterface {
return $this->properties;
}
/**
* @inheritDoc
*/
public function setProperties(NodePropertiesMutableInterface $value): static {
if (!$value instanceof EntityPropertiesMutableInterface) {
throw new \InvalidArgumentException('Properties must implement EntityPropertiesMutableInterface');
}
$this->properties->setDataRaw($value->getDataRaw());
return $this;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Entity;
use KTXF\Resource\Provider\Node\NodeMutableInterface;
/**
* @method static setProperties(EntityPropertiesMutableInterface $value)
*/
interface EntityMutableInterface extends EntityBaseInterface, NodeMutableInterface {
public const JSON_TYPE = EntityBaseInterface::JSON_TYPE;
/**
* Gets the entity properties (mutable)
*
* @since 2025.05.01
*/
public function getProperties(): EntityPropertiesMutableInterface;
}

View File

@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Entity;
use JsonSerializable;
enum EntityPermissions: string implements JsonSerializable {
case View = 'view';
case Modify = 'modify';
case Delete = 'delete';
case Share = 'share';
public function jsonSerialize(): string {
return $this->value;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Entity;
use KTXF\Resource\Provider\Node\NodePropertiesBaseAbstract;
abstract class EntityPropertiesBaseAbstract extends NodePropertiesBaseAbstract implements EntityPropertiesBaseInterface {
public const JSON_TYPE = EntityPropertiesBaseInterface::JSON_TYPE;
public function getDataRaw(): array|string|null {
return $this->data[self::JSON_PROPERTY_DATA] ?? null;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Entity;
use KTXF\Resource\Provider\Node\NodePropertiesBaseInterface;
interface EntityPropertiesBaseInterface extends NodePropertiesBaseInterface {
public const JSON_TYPE = 'chrono:entity';
public const JSON_PROPERTY_DATA = 'data';
public function getDataRaw(): array|string|null;
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Entity;
abstract class EntityPropertiesMutableAbstract extends EntityPropertiesBaseAbstract implements EntityPropertiesMutableInterface {
public const JSON_TYPE = EntityPropertiesBaseInterface::JSON_TYPE;
public function jsonDeserialize(array|string $data): static {
if (is_string($data)) {
$data = json_decode($data, true);
}
$this->data = $data;
return $this;
}
public function setDataRaw(array|string|null $value): static {
$this->data[self::JSON_PROPERTY_DATA] = $value;
return $this;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Entity;
use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface;
interface EntityPropertiesMutableInterface extends EntityPropertiesBaseInterface, NodePropertiesMutableInterface {
public const JSON_TYPE = EntityPropertiesBaseInterface::JSON_TYPE;
public function setDataRaw(array|string|null $value): static;
}

View File

@@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Entity;
use DateTimeImmutable;
interface IEntityBase extends \JsonSerializable {
public const JSON_TYPE = 'chrono.entity';
public const JSON_PROPERTY_TYPE = '@type';
public const JSON_PROPERTY_IN = 'in';
public const JSON_PROPERTY_ID = 'id';
public const JSON_PROPERTY_DATA = 'data';
public const JSON_PROPERTY_CREATED = 'created';
public const JSON_PROPERTY_MODIFIED = 'modified';
public const JSON_PROPERTY_SIGNATURE = 'signature';
/**
* Unique arbitrary text string identifying the collection this entity belongs to (e.g. 1 or Collection1 or anything else)
*
* @since 2025.05.01
*/
public function in(): string|int;
/**
* Unique arbitrary text string identifying this entity (e.g. 1 or Entity or anything else)
*
* @since 2025.05.01
*/
public function id(): string|int;
/**
* Gets the creation date of this entity
*/
public function created(): ?DateTimeImmutable;
/**
* Gets the modification date of this entity
*/
public function modified(): ?DateTimeImmutable;
/**
* Gets the signature of this entity
*
* @since 2025.05.01
*/
public function signature(): ?string;
/**
* Gets the priority of this entity
*
* @since 2025.05.01
*/
public function getPriority(): ?int;
/**
* Gets the visibility of this entity
*
* @since 2025.05.01
*/
public function getVisibility(): ?bool;
/**
* Gets the color of this entity
*
* @since 2025.05.01
*/
public function getColor(): ?string;
/**
* Gets the object data (event, task, or journal).
*
* @since 2025.05.01
*/
public function getDataObject(): object|null;
/**
* Gets the raw data as an associative array or JSON string.
*
* @since 2025.05.01
*
* @return array|string|null
*/
public function getDataJson(): array|string|null;
}

View File

@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Entity;
use KTXF\Json\JsonDeserializable;
interface IEntityMutable extends IEntityBase, JsonDeserializable {
/**
* Sets the priority of this entity
*
* @since 2025.05.01
*/
public function setPriority(?int $value): static;
/**
* Sets the visibility of this entity
*
* @since 2025.05.01
*/
public function setVisibility(?bool $value): static;
/**
* Sets the color of this entity
*
* @since 2025.05.01
*/
public function setColor(?string $value): static;
/**
* Sets the object as a class instance.
*
* @since 2025.05.01
*/
public function setDataObject(object $value): static;
/**
* Sets the object data from a json string
*
* @since 2025.05.01
*/
public function setDataJson(array|string $value): static;
}

View File

@@ -1,96 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Provider;
use JsonSerializable;
use KTXF\Chrono\Service\IServiceBase;
interface IProviderBase extends JsonSerializable {
public const CAPABILITY_SERVICE_LIST = 'ServiceList';
public const CAPABILITY_SERVICE_FETCH = 'ServiceFetch';
public const CAPABILITY_SERVICE_EXTANT = 'ServiceExtant';
public const JSON_TYPE = 'chrono.provider';
public const JSON_PROPERTY_TYPE = '@type';
public const JSON_PROPERTY_ID = 'id';
public const JSON_PROPERTY_LABEL = 'label';
public const JSON_PROPERTY_CAPABILITIES = 'capabilities';
/**
* Confirms if specific capability is supported (e.g. 'ServiceList')
*
* @since 2025.05.01
*/
public function capable(string $value): bool;
/**
* Lists all supported capabilities
*
* @since 2025.05.01
*
* @return array<string,bool>
*/
public function capabilities(): array;
/**
* An arbitrary unique text string identifying this provider (e.g. UUID or 'system' or anything else)
*
* @since 2025.05.01
*/
public function id(): string;
/**
* The localized human friendly name of this provider (e.g. System Calendar Provider)
*
* @since 2025.05.01
*/
public function label(): string;
/**
* Retrieve collection of services for a specific user
*
* @since 2025.05.01
*
* @param string $tenantId tenant identifier
* @param string $userId user identifier
* @param array $filter filter criteria
*
* @return array<string,IServiceBase> collection of service objects
*/
public function serviceList(string $tenantId, string $userId, array $filter): array;
/**
* Determine if any services are configured for a specific user
*
* @since 2025.05.01
*
* @param string $tenantId tenant identifier
* @param string $userId user identifier
* @param int|string ...$identifiers variadic collection of service identifiers
*
* @return array<string,bool> collection of service identifiers with boolean values indicating if the service is available
*/
public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array;
/**
* Retrieve a service with a specific identifier
*
* @since 2025.05.01
*
* @param string $tenantId tenant identifier
* @param string $userId user identifier
* @param string|int $identifier service identifier
*
* @return IServiceBase|null returns service object or null if non found
*/
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase;
}

View File

@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Provider;
use KTXF\Json\JsonDeserializable;
use KTXF\Chrono\Service\IServiceBase;
interface IProviderServiceMutate extends JsonDeserializable {
public const CAPABILITY_SERVICE_FRESH = 'ServiceFresh';
public const CAPABILITY_SERVICE_CREATE = 'ServiceCreate';
public const CAPABILITY_SERVICE_UPDATE = 'ServiceUpdate';
public const CAPABILITY_SERVICE_DESTROY = 'ServiceDestroy';
/**
* construct and new blank service instance
*
* @since 2025.05.01
*/
public function serviceFresh(string $uid = ''): IServiceBase;
/**
* create a service configuration for a specific user
*
* @since 2025.05.01
*/
public function serviceCreate(string $uid, IServiceBase $service): string;
/**
* modify a service configuration for a specific user
*
* @since 2025.05.01
*/
public function serviceModify(string $uid, IServiceBase $service): string;
/**
* delete a service configuration for a specific user
*
* @since 2025.05.01
*/
public function serviceDestroy(string $uid, IServiceBase $service): bool;
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXF\Chrono\Provider;
use KTXF\Resource\Provider\ResourceProviderBaseInterface;
/**
* Chrono Provider Base Interface
*
* @since 2025.05.01
*/
interface ProviderBaseInterface extends ResourceProviderBaseInterface{
public const JSON_TYPE = 'chrono:provider';
}

Some files were not shown because too many files have changed in this diff Show More