Files
project-manager/src/Support/Config.php
T
aneurinandClaude Sonnet 5 c66e5ceb9b Rename lists -> projects and items -> cards throughout
Project scope shifts from a todo list to a project-management app. This is a
straight terminology rename across code, comments, migrations, tests, and
docs — no behaviour change.

- DB: table todo_lists -> projects, todo_items -> cards, column
  todo_items.list_id -> cards.project_id, indexes renamed. Migrations 003/004
  rewritten in place (destructive; recreate the volume with `down -v`).
- API: /api/lists -> /api/projects, nested /items -> /cards, reorder body
  item_ids -> card_ids, JSON keys list/lists/item/items -> project/projects/
  card/cards, item_count -> card_count, list_id -> project_id, and the
  matching error messages.
- PHP: TodoList/TodoItem Repository + Controller -> Project/Card; shared SQL
  aliases l/i -> p/c.
- Frontend: stores lists.ts/items.ts -> projects.ts/cards.ts (useProjectsStore
  / useCardsStore, MAX_PROJECTS), ListView -> ProjectView, TodoItemRow ->
  CardRow, route /lists/:id -> /projects/:id (name "project"), types TodoList/
  TodoItem -> Project/Card, and all UI copy. CSS .lists*/.list-head* ->
  .projects*/.project-head*, .item* -> .card-row* (kept the generic .card
  panel class), .items -> .cards.
- Product name in the header, PWA manifest, index.html title and package
  descriptions -> "Project Manager" / "Projects".

Backend suite: 37 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 11:28:59 +01:00

99 lines
3.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support;
/**
* Immutable application configuration, resolved from environment variables with
* development-friendly defaults.
*/
final class Config
{
public function __construct(
public readonly string $databasePath,
public readonly string $jwtSecret,
public readonly int $jwtTtl,
public readonly bool $displayErrors,
/** Base URL of the frontend, used to build magic links. */
public readonly string $appUrl,
public readonly MailConfig $mail,
) {
}
public static function load(string $basePath): self
{
if (is_file($basePath . '/.env')) {
\Dotenv\Dotenv::createImmutable($basePath)->safeLoad();
}
$storagePath = self::env('STORAGE_PATH', $basePath . '/storage');
if (!is_dir($storagePath)) {
mkdir($storagePath, 0775, true);
}
$databasePath = self::env('DATABASE_PATH', $storagePath . '/database.sqlite');
if (!self::isAbsolutePath($databasePath)) {
$databasePath = $basePath . '/' . ltrim($databasePath, '/');
}
$jwtSecret = self::env('JWT_SECRET') ?? self::resolveSecret($storagePath . '/secret.key');
$jwtTtl = (int) (self::env('JWT_TTL') ?? '86400');
$displayErrors = filter_var(self::env('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOL);
$appUrl = rtrim(self::env('APP_URL', 'http://localhost:5173'), '/');
$mailLogPath = self::env('MAIL_LOG_PATH', $storagePath . '/mail.log');
if (!self::isAbsolutePath($mailLogPath)) {
$mailLogPath = $basePath . '/' . ltrim($mailLogPath, '/');
}
$mail = new MailConfig(
transport: strtolower(self::env('MAIL_TRANSPORT', 'mail')),
fromAddress: self::env('MAIL_FROM', 'no-reply@todo.test'),
fromName: self::env('MAIL_FROM_NAME', 'Projects'),
logPath: $mailLogPath,
smtpHost: self::env('MAIL_SMTP_HOST'),
smtpPort: (int) (self::env('MAIL_SMTP_PORT') ?? '587'),
smtpUsername: self::env('MAIL_SMTP_USERNAME'),
smtpPassword: self::env('MAIL_SMTP_PASSWORD'),
smtpEncryption: strtolower(self::env('MAIL_SMTP_ENCRYPTION', 'tls')),
);
return new self($databasePath, $jwtSecret, $jwtTtl, $displayErrors, $appUrl, $mail);
}
private static function env(string $key, ?string $default = null): ?string
{
$value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
if ($value === false || $value === null || $value === '') {
return $default;
}
return (string) $value;
}
private static function isAbsolutePath(string $path): bool
{
return str_starts_with($path, '/') || preg_match('#^[A-Za-z]:[\\\\/]#', $path) === 1;
}
/**
* Return the persisted signing secret, generating and storing one on first run
* so local development works with zero configuration.
*/
private static function resolveSecret(string $path): string
{
if (is_file($path)) {
return trim((string) file_get_contents($path));
}
$secret = bin2hex(random_bytes(32));
file_put_contents($path, $secret);
@chmod($path, 0600);
return $secret;
}
}