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:
parent
f310643064
commit
2cfc5e8f17
22 changed files with 2056 additions and 66 deletions
|
|
@ -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],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
1646
config/reference.php
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +0,0 @@
|
|||
api:
|
||||
resource: '../src/Infrastructure/Http/Controller/Api/'
|
||||
type: attribute
|
||||
prefix: /api
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
[www]
|
||||
listen = /var/run/php/php-fpm.sock
|
||||
listen.mode = 0660
|
||||
30
migrations/Version20260514053908.php
Normal file
30
migrations/Version20260514053908.php
Normal 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');
|
||||
}
|
||||
}
|
||||
39
migrations/Version20260514054123.php
Normal file
39
migrations/Version20260514054123.php
Normal 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');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260514054903.php
Normal file
31
migrations/Version20260514054903.php
Normal 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)');
|
||||
}
|
||||
}
|
||||
|
|
@ -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' => []];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
src/Infrastructure/Console/BackupCommand.php
Normal file
102
src/Infrastructure/Console/BackupCommand.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/Infrastructure/Console/CreateUserCommand.php
Normal file
77
src/Infrastructure/Console/CreateUserCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
41
src/Infrastructure/Console/RotateLogsCommand.php
Normal file
41
src/Infrastructure/Console/RotateLogsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
|
|
|
|||
Loading…
Reference in a new issue