feat: add console commands, remaining migrations and config wiring

Console commands: CreateUser (interactive), BackupCommand, RotateLogsCommand.
Migrations 20260514: initial schema for app/logs schemas.
Config: register new bundles, Doctrine schema filter, Kernel micro-kernel
adjustments, deleted unused api.yaml route file and www.conf override.
Application service and API controller updates for the full article lifecycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-17 22:44:11 +00:00
parent f310643064
commit 2cfc5e8f17
22 changed files with 2056 additions and 66 deletions

View file

@ -5,4 +5,11 @@ return [
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
EasyCorp\Bundle\EasyAdminBundle\EasyAdminBundle::class => ['all' => true],
Endroid\QrCodeBundle\EndroidQrCodeBundle::class => ['all' => true],
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
];

View file

@ -1,7 +1,7 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
schema_filter: ~^(?!logs\.|logs_archive\.)~
schema_filter: ~^(?!logs_archive\.)~
orm:
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true

1646
config/reference.php Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,4 +0,0 @@
api:
resource: '../src/Infrastructure/Http/Controller/Api/'
type: attribute
prefix: /api

View file

@ -1,3 +0,0 @@
[www]
listen = /var/run/php/php-fpm.sock
listen.mode = 0660

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260514053908 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE app.api_keys ADD key_prefix VARCHAR(8) NOT NULL DEFAULT ''");
$this->addSql('ALTER TABLE app.api_keys ALTER COLUMN key_prefix DROP DEFAULT');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE app.api_keys DROP key_prefix');
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260514054123 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SCHEMA IF NOT EXISTS logs');
$this->addSql('CREATE SCHEMA IF NOT EXISTS logs_archive');
$this->addSql('CREATE SEQUENCE logs.log_entries_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE logs.log_entries (id BIGINT NOT NULL, channel VARCHAR(50) NOT NULL, level INT NOT NULL, level_name VARCHAR(50) NOT NULL, message TEXT NOT NULL, context JSON NOT NULL, extra JSON NOT NULL, logged_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('COMMENT ON COLUMN logs.log_entries.logged_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE INDEX idx_log_entries_channel ON logs.log_entries (channel)');
$this->addSql('CREATE INDEX idx_log_entries_level ON logs.log_entries (level)');
$this->addSql('CREATE INDEX idx_log_entries_logged_at ON logs.log_entries (logged_at)');
$this->addSql('ALTER SEQUENCE logs.log_entries_id_seq OWNED BY logs.log_entries.id');
$this->addSql('ALTER TABLE logs.log_entries ALTER id SET DEFAULT nextval(\'logs.log_entries_id_seq\')');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE logs.log_entries');
$this->addSql('DROP SEQUENCE logs.log_entries_id_seq CASCADE');
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260514054903 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE app.ai_pipeline_jobs DROP CONSTRAINT IF EXISTS fk_4a59a35d7294869c');
$this->addSql('DROP INDEX IF EXISTS app.idx_4a59a35d7294869c');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE app.ai_pipeline_jobs ADD CONSTRAINT fk_4a59a35d7294869c FOREIGN KEY (article_id) REFERENCES app.articles (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_4a59a35d7294869c ON app.ai_pipeline_jobs (article_id)');
}
}

View file

@ -10,7 +10,9 @@ use App\Domain\Article\ArticleStatus;
use App\Domain\Article\AttributeValue;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use App\Infrastructure\Messenger\Message\PublishToChannelMessage;
use Doctrine\DBAL\Connection;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Uid\Uuid;
final class ArticleService
@ -20,6 +22,7 @@ final class ArticleService
private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
private readonly ArticleValidator $validator,
private readonly Connection $connection,
private readonly MessageBusInterface $bus,
) {
}
@ -28,11 +31,12 @@ final class ArticleService
ArticleCondition $condition,
int $stock = 1,
?string $conditionNotes = null,
?string $inventoryNumber = null,
): Article {
$articleType = $this->articleTypeRepository->findById($articleTypeId)
?? throw new \DomainException("ArticleType {$articleTypeId->toRfc4122()} not found");
$inventoryNumber = $this->nextInventoryNumber();
$inventoryNumber = $inventoryNumber ?? $this->nextInventoryNumber();
$sku = 'ART-'.mb_strtoupper(substr(str_replace('-', '', Uuid::v7()->toRfc4122()), 0, 8));
$article = new Article($articleType, $sku, $inventoryNumber, $stock, $condition);
@ -126,6 +130,8 @@ final class ArticleService
$article->transitionTo(ArticleStatus::Active);
$this->articleRepository->save($article);
$this->bus->dispatch(new PublishToChannelMessage($article->getId()->toRfc4122()));
return ['article' => $article, 'missing' => []];
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Application\Article;
use App\Application\Storage\StorageManagerInterface;
use App\Application\Storage\StoredFile;
use App\Domain\Article\ArticlePhoto;
use App\Domain\Article\Repository\ArticlePhotoRepositoryInterface;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
@ -19,6 +20,11 @@ final class PhotoService
) {
}
public function uploadRaw(string $tempPath, string $originalFilename): StoredFile
{
return $this->storageManager->store($tempPath, $originalFilename);
}
public function upload(Uuid $articleId, string $tempPath, string $originalFilename): ArticlePhoto
{
$article = $this->articleRepository->findById($articleId)

View file

@ -16,6 +16,8 @@ interface ArticleRepositoryInterface
public function findByInventoryNumber(string $inventoryNumber): ?Article;
public function findByEbayListingId(string $ebayListingId): ?Article;
/** @return list<Article> */
public function findByStatus(ArticleStatus $status): array;

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Domain\Pipeline;
use App\Domain\Article\Article;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
@ -19,9 +18,8 @@ class AIPipelineJob
#[ORM\Column(type: 'string', enumType: AIPipelineJobType::class)]
private AIPipelineJobType $type;
#[ORM\ManyToOne(targetEntity: Article::class)]
#[ORM\JoinColumn(nullable: true)]
private ?Article $article = null;
#[ORM\Column(type: 'uuid', nullable: true)]
private ?Uuid $articleId = null;
#[ORM\Column(type: 'string', enumType: AIPipelineJobStatus::class)]
private AIPipelineJobStatus $status;
@ -39,7 +37,7 @@ class AIPipelineJob
/** @var list<string>|null */
#[ORM\Column(type: 'simple_array', nullable: true)]
private ?array $missingFields = [];
private ?array $missingFields = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $errorMessage = null;
@ -50,9 +48,7 @@ class AIPipelineJob
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $completedAt = null;
/**
* @param array<string, mixed> $inputData
*/
/** @param array<string, mixed> $inputData */
public function __construct(AIPipelineJobType $type, array $inputData)
{
$this->id = Uuid::v7();
@ -62,29 +58,6 @@ class AIPipelineJob
$this->createdAt = new \DateTimeImmutable();
}
public function incrementAttempt(): void
{
++$this->attemptCount;
}
public function markCompleted(): void
{
$this->status = AIPipelineJobStatus::Completed;
$this->completedAt = new \DateTimeImmutable();
}
public function markFailed(string $errorMessage): void
{
$this->status = AIPipelineJobStatus::Failed;
$this->errorMessage = $errorMessage;
}
public function markNeedsReview(string $reason): void
{
$this->status = AIPipelineJobStatus::NeedsReview;
$this->errorMessage = $reason;
}
public function getId(): Uuid
{
return $this->id;
@ -95,14 +68,14 @@ class AIPipelineJob
return $this->type;
}
public function getArticle(): ?Article
public function getArticleId(): ?Uuid
{
return $this->article;
return $this->articleId;
}
public function setArticle(Article $article): void
public function setArticleId(?Uuid $id): void
{
$this->article = $article;
$this->articleId = $id;
}
public function getStatus(): AIPipelineJobStatus
@ -110,11 +83,6 @@ class AIPipelineJob
return $this->status;
}
public function setStatus(AIPipelineJobStatus $status): void
{
$this->status = $status;
}
public function getAttemptCount(): int
{
return $this->attemptCount;
@ -132,24 +100,12 @@ class AIPipelineJob
return $this->outputData;
}
/** @param array<string, mixed> $outputData */
public function setOutputData(array $outputData): void
{
$this->outputData = $outputData;
}
/** @return list<string> */
public function getMissingFields(): array
{
return $this->missingFields ?? [];
}
/** @param list<string> $fields */
public function setMissingFields(array $fields): void
{
$this->missingFields = $fields;
}
public function getErrorMessage(): ?string
{
return $this->errorMessage;
@ -164,4 +120,38 @@ class AIPipelineJob
{
return $this->completedAt;
}
public function markProcessing(): void
{
$this->status = AIPipelineJobStatus::Processing;
}
/** @param array<string, mixed> $outputData */
public function markCompleted(array $outputData = []): void
{
$this->status = AIPipelineJobStatus::Completed;
$this->outputData = $outputData;
$this->completedAt = new \DateTimeImmutable();
}
public function markFailed(string $reason): void
{
$this->status = AIPipelineJobStatus::Failed;
$this->errorMessage = $reason;
$this->completedAt = new \DateTimeImmutable();
}
public function markNeedsReview(string $reason): void
{
$this->status = AIPipelineJobStatus::NeedsReview;
$this->errorMessage = $reason;
$this->completedAt = new \DateTimeImmutable();
}
/** @param list<string> $missing */
public function incrementAttempt(array $missing = []): void
{
++$this->attemptCount;
$this->missingFields = $missing;
}
}

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Console;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:backup', description: 'Create a PostgreSQL dump and prune backups older than 14 days')]
final class BackupCommand extends Command
{
private const RETENTION_DAYS = 14;
public function __construct(
private readonly string $backupDir,
private readonly string $databaseUrl,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (!is_dir($this->backupDir) && !mkdir($this->backupDir, 0750, true) && !is_dir($this->backupDir)) {
$io->error(\sprintf('Cannot create backup directory: %s', $this->backupDir));
return Command::FAILURE;
}
$timestamp = (new \DateTimeImmutable())->format('Ymd_His');
$filename = \sprintf('%s/db_backup_%s.sql.gz', rtrim($this->backupDir, '/'), $timestamp);
$parsed = parse_url($this->databaseUrl);
if (false === $parsed) {
$io->error('Invalid DATABASE_URL');
return Command::FAILURE;
}
$host = $parsed['host'] ?? 'localhost';
$port = $parsed['port'] ?? 5432;
$user = $parsed['user'] ?? 'postgres';
$pass = $parsed['pass'] ?? '';
$db = ltrim($parsed['path'] ?? '/postgres', '/');
$env = ['PGPASSWORD' => $pass];
$cmd = \sprintf(
'pg_dump -h %s -p %d -U %s %s | gzip > %s',
escapeshellarg($host),
(int) $port,
escapeshellarg($user),
escapeshellarg($db),
escapeshellarg($filename),
);
$descriptorSpec = [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']];
$envVars = array_merge($_ENV, $env);
$proc = proc_open($cmd, $descriptorSpec, $pipes, null, $envVars);
if (!\is_resource($proc)) {
$io->error('Failed to launch pg_dump.');
return Command::FAILURE;
}
fclose($pipes[0]);
fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$exitCode = proc_close($proc);
if (0 !== $exitCode) {
$io->error(\sprintf('pg_dump failed: %s', $stderr));
return Command::FAILURE;
}
$io->success(\sprintf('Database backed up to %s', $filename));
$this->pruneOldBackups($io);
return Command::SUCCESS;
}
private function pruneOldBackups(SymfonyStyle $io): void
{
$cutoff = time() - (self::RETENTION_DAYS * 86400);
$files = glob($this->backupDir.'/db_backup_*.sql.gz') ?: [];
foreach ($files as $file) {
if (filemtime($file) < $cutoff) {
unlink($file);
$io->writeln(\sprintf('Pruned old backup: %s', basename($file)));
}
}
}
}

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Console;
use App\Domain\Auth\Repository\UserRepositoryInterface;
use App\Domain\Auth\User;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(name: 'app:users:create', description: 'Interactively create an admin user')]
final class CreateUserCommand extends Command
{
public function __construct(
private readonly UserRepositoryInterface $userRepository,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = $io->ask('Email address');
if (!\is_string($email) || '' === $email || !filter_var($email, \FILTER_VALIDATE_EMAIL)) {
$io->error('A valid email address is required.');
return Command::FAILURE;
}
if (null !== $this->userRepository->findByEmail($email)) {
$io->error(\sprintf('A user with email "%s" already exists.', $email));
return Command::FAILURE;
}
$password = $io->askHidden('Password (min 12 characters)');
if (!\is_string($password) || \strlen($password) < 12) {
$io->error('Password must be at least 12 characters.');
return Command::FAILURE;
}
$confirm = $io->askHidden('Confirm password');
if ($password !== $confirm) {
$io->error('Passwords do not match.');
return Command::FAILURE;
}
$placeholder = new User($email, '');
$hash = $this->passwordHasher->hashPassword($placeholder, $password);
$user = new User($email, $hash);
$permissions = $io->ask('Comma-separated permissions to grant (leave empty for none)', '');
if (\is_string($permissions) && '' !== $permissions) {
foreach (array_map('trim', explode(',', $permissions)) as $perm) {
if ('' !== $perm) {
$user->grantPermission($perm);
}
}
}
$this->userRepository->save($user);
$io->success(\sprintf('User "%s" created successfully (ID: %s).', $email, $user->getId()->toRfc4122()));
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Console;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:logs:rotate', description: 'Archive log entries older than 90 days to logs_archive schema')]
final class RotateLogsCommand extends Command
{
public function __construct(private readonly Connection $connection)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$cutoff = (new \DateTimeImmutable('-90 days'))->format('Y-m-d H:i:s');
$this->connection->executeStatement(
'CREATE TABLE IF NOT EXISTS logs_archive.log_entries (LIKE logs.log_entries INCLUDING ALL)',
);
$moved = $this->connection->executeStatement(
'WITH moved AS (DELETE FROM logs.log_entries WHERE logged_at < :cutoff RETURNING *)
INSERT INTO logs_archive.log_entries SELECT * FROM moved',
['cutoff' => $cutoff],
);
$io->success(\sprintf('Archived %d log entries older than 90 days.', $moved));
return Command::SUCCESS;
}
}

View file

@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
#[Route('/articles', name: 'api_articles_')]
#[Route('/api/articles', name: 'api_articles_')]
final class ArticleController extends AbstractController
{
public function __construct(private readonly ArticleService $service)

View file

@ -13,7 +13,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
#[Route('/article-types', name: 'api_article_types_')]
#[Route('/api/article-types', name: 'api_article_types_')]
final class ArticleTypeController extends AbstractController
{
public function __construct(private readonly ArticleTypeService $service)

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Api;
use App\Application\Channel\MappingService;
use App\Infrastructure\Channel\Ebay\EbayTaxonomyService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@ -12,7 +13,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
#[Route('/article-types/{typeId}/platform-configs', name: 'api_mapping_')]
#[Route('/api/article-types/{typeId}/platform-configs', name: 'api_mapping_')]
final class MappingController extends AbstractController
{
public function __construct(private readonly MappingService $service)
@ -87,4 +88,16 @@ final class MappingController extends AbstractController
return $this->json(null, Response::HTTP_NO_CONTENT);
}
#[Route('/ebay-category-aspects/{categoryId}', name: 'ebay_aspects', methods: ['GET'])]
public function ebayAspects(string $typeId, string $categoryId, EbayTaxonomyService $taxonomy): JsonResponse
{
try {
$aspects = $taxonomy->getCategoryAspects($categoryId);
} catch (\Throwable $e) {
return $this->json(['error' => 'eBay API error: '.$e->getMessage()], Response::HTTP_SERVICE_UNAVAILABLE);
}
return $this->json($aspects);
}
}

View file

@ -13,7 +13,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
#[Route('/articles/{articleId}/photos', name: 'api_photos_')]
#[Route('/api/articles/{articleId}/photos', name: 'api_photos_')]
final class PhotoController extends AbstractController
{
public function __construct(private readonly PhotoService $service)

View file

@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
#[Route('/platforms', name: 'api_platforms_')]
#[Route('/api/platforms', name: 'api_platforms_')]
final class PlatformController extends AbstractController
{
public function __construct(private readonly PlatformService $service)

View file

@ -31,6 +31,11 @@ final class DoctrineArticleRepository implements ArticleRepositoryInterface
return $this->em->getRepository(Article::class)->findOneBy(['inventoryNumber' => $inventoryNumber]);
}
public function findByEbayListingId(string $ebayListingId): ?Article
{
return $this->em->getRepository(Article::class)->findOneBy(['ebayListingId' => $ebayListingId]);
}
/** @return list<Article> */
public function findByStatus(ArticleStatus $status): array
{

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;