Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions src/Controller/Api/BulkGenerateController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php declare(strict_types=1);

namespace Frosh\AI\Controller\Api;

use Frosh\AI\Client\AiClient;
use Frosh\AI\Entity\BulkJob\BulkJobEntity;
use Frosh\AI\Feature\FeatureFlags;
use Frosh\AI\Feature\FeatureId;
use Frosh\AI\Message\BulkGenerateMessage;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\Routing\ApiRouteScope;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\PlatformRequest;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;

#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [ApiRouteScope::ID]])]
class BulkGenerateController extends AbstractController
{
/**
* @param EntityRepository<BulkJobEntity> $bulkJobRepository
*/
public function __construct(
private readonly EntityRepository $bulkJobRepository,
private readonly MessageBusInterface $messageBus,
private readonly FeatureFlags $featureFlags,
private readonly AiClient $ai,
) {
}

#[Route(
path: '/api/_action/frosh-ai/product/bulk-generate',
name: 'api.action.frosh_ai.product.bulk_generate',
methods: ['POST'],
)]
public function enqueue(Request $request, Context $context): JsonResponse
{
/** @var array<string, mixed> $payload */
$payload = $request->toArray();

$productIds = $this->stringList($payload['productIds'] ?? null);
if ($productIds === []) {
return new JsonResponse(['success' => false, 'error' => 'productIds must be a non-empty list.'], 400);
}

if (!$this->featureFlags->isMasterEnabled()) {
return new JsonResponse(['success' => false, 'error' => 'Frosh AI features are disabled. Enable them under Settings → Frosh AI.'], 400);
}

if (!$this->featureFlags->isFeatureFlagOn(FeatureId::BulkGeneration)) {
return new JsonResponse(['success' => false, 'error' => 'Bulk generation is disabled. Enable it under Settings → Frosh AI → Features.'], 400);
}

if (!$this->ai->isAvailable()) {
return new JsonResponse(['success' => false, 'error' => 'No AI provider is configured. Open Settings → Frosh AI.'], 400);
}

$jobContext = array_filter([
'tone' => $payload['tone'] ?? null,
'language' => $payload['language'] ?? null,
'languageId' => $payload['languageId'] ?? null,
'languageName' => $payload['languageName'] ?? null,
'maxWords' => $payload['maxWords'] ?? null,
], static fn (mixed $value): bool => $value !== null);

$jobId = Uuid::randomHex();

try {
$this->bulkJobRepository->create([[
'id' => $jobId,
'type' => 'product-description',
'status' => 'queued',
'total' => \count($productIds),
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
'entityIds' => $productIds,
'errors' => [],
'context' => $jobContext,
]], $context);

$this->messageBus->dispatch(new BulkGenerateMessage($jobId));
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'error' => $e->getMessage()], 500);
}

return new JsonResponse([
'success' => true,
'jobId' => $jobId,
'total' => \count($productIds),
]);
}

#[Route(
path: '/api/_action/frosh-ai/bulk-job/{jobId}',
name: 'api.action.frosh_ai.bulk_job.status',
methods: ['GET'],
)]
public function status(string $jobId, Context $context): JsonResponse
{
/** @var BulkJobEntity|null $job */
$job = $this->bulkJobRepository->search(new Criteria([$jobId]), $context)->first();

if ($job === null) {
return new JsonResponse(['success' => false, 'error' => 'Bulk job not found.'], 404);
}

return new JsonResponse([
'success' => true,
'jobId' => $job->getId(),
'type' => $job->getType(),
'status' => $job->getStatus(),
'total' => $job->getTotal(),
'processed' => $job->getProcessed(),
'succeeded' => $job->getSucceeded(),
'failed' => $job->getFailed(),
'errors' => $job->getErrors() ?? [],
]);
}

/**
* @return list<string>
*/
private function stringList(mixed $value): array
{
if (!\is_array($value)) {
return [];
}

$out = [];
foreach ($value as $item) {
if (!\is_string($item)) {
continue;
}
$trimmed = trim($item);
if ($trimmed !== '' && Uuid::isValid($trimmed)) {
$out[] = $trimmed;
}
}

return array_values(array_unique($out));
}
}
16 changes: 16 additions & 0 deletions src/Entity/BulkJob/BulkJobCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php declare(strict_types=1);

namespace Frosh\AI\Entity\BulkJob;

use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;

/**
* @extends EntityCollection<BulkJobEntity>
*/
class BulkJobCollection extends EntityCollection
{
protected function getExpectedClass(): string
{
return BulkJobEntity::class;
}
}
53 changes: 53 additions & 0 deletions src/Entity/BulkJob/BulkJobDefinition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php declare(strict_types=1);

namespace Frosh\AI\Entity\BulkJob;

use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\ApiAware;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\PrimaryKey;
use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\JsonField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField;
use Shopware\Core\Framework\DataAbstractionLayer\Field\UpdatedAtField;
use Shopware\Core\Framework\DataAbstractionLayer\FieldCollection;

class BulkJobDefinition extends EntityDefinition
{
final public const ENTITY_NAME = 'frosh_ai_bulk_job';

public function getEntityName(): string
{
return self::ENTITY_NAME;
}

public function getEntityClass(): string
{
return BulkJobEntity::class;
}

public function getCollectionClass(): string
{
return BulkJobCollection::class;
}

protected function defineFields(): FieldCollection
{
return new FieldCollection([
(new IdField('id', 'id'))->addFlags(new PrimaryKey(), new Required(), new ApiAware()),
(new StringField('type', 'type', 32))->addFlags(new Required(), new ApiAware()),
(new StringField('status', 'status', 16))->addFlags(new Required(), new ApiAware()),
(new IntField('total', 'total'))->addFlags(new ApiAware()),
(new IntField('processed', 'processed'))->addFlags(new ApiAware()),
(new IntField('succeeded', 'succeeded'))->addFlags(new ApiAware()),
(new IntField('failed', 'failed'))->addFlags(new ApiAware()),
(new JsonField('entity_ids', 'entityIds'))->addFlags(new ApiAware()),
(new JsonField('errors', 'errors'))->addFlags(new ApiAware()),
(new JsonField('context', 'context'))->addFlags(new ApiAware()),
new CreatedAtField(),
new UpdatedAtField(),
]);
}
}
140 changes: 140 additions & 0 deletions src/Entity/BulkJob/BulkJobEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php declare(strict_types=1);

namespace Frosh\AI\Entity\BulkJob;

use Shopware\Core\Framework\DataAbstractionLayer\Entity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityIdTrait;

class BulkJobEntity extends Entity
{
use EntityIdTrait;

protected string $type = 'product-description';

protected string $status = 'queued';

protected int $total = 0;

protected int $processed = 0;

protected int $succeeded = 0;

protected int $failed = 0;

/** @var list<string>|null */
protected ?array $entityIds = null;

/** @var list<array{entityId: string, message: string}>|null */
protected ?array $errors = null;

/** @var array<string, mixed>|null */
protected ?array $context = null;

public function getType(): string
{
return $this->type;
}

public function setType(string $type): void
{
$this->type = $type;
}

public function getStatus(): string
{
return $this->status;
}

public function setStatus(string $status): void
{
$this->status = $status;
}

public function getTotal(): int
{
return $this->total;
}

public function setTotal(int $total): void
{
$this->total = $total;
}

public function getProcessed(): int
{
return $this->processed;
}

public function setProcessed(int $processed): void
{
$this->processed = $processed;
}

public function getSucceeded(): int
{
return $this->succeeded;
}

public function setSucceeded(int $succeeded): void
{
$this->succeeded = $succeeded;
}

public function getFailed(): int
{
return $this->failed;
}

public function setFailed(int $failed): void
{
$this->failed = $failed;
}

/**
* @return list<string>|null
*/
public function getEntityIds(): ?array
{
return $this->entityIds;
}

/**
* @param list<string>|null $entityIds
*/
public function setEntityIds(?array $entityIds): void
{
$this->entityIds = $entityIds;
}

/**
* @return list<array{entityId: string, message: string}>|null
*/
public function getErrors(): ?array
{
return $this->errors;
}

/**
* @param list<array{entityId: string, message: string}>|null $errors
*/
public function setErrors(?array $errors): void
{
$this->errors = $errors;
}

/**
* @return array<string, mixed>|null
*/
public function getContext(): ?array
{
return $this->context;
}

/**
* @param array<string, mixed>|null $context
*/
public function setContext(?array $context): void
{
$this->context = $context;
}
}
3 changes: 3 additions & 0 deletions src/Feature/FeatureId.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ enum FeatureId: string
case CategoryDescription = 'category_description';
case ManufacturerDescription = 'manufacturer_description';
case ReviewSummary = 'review_summary';
case BulkGeneration = 'bulk_generation';
case ImageGeneration = 'image_generation';
case ImageEdit = 'image_edit';

Expand All @@ -26,6 +27,7 @@ public function configKey(): string
self::CategoryDescription => 'FroshAI.config.featureCategoryDescription',
self::ManufacturerDescription => 'FroshAI.config.featureManufacturerDescription',
self::ReviewSummary => 'FroshAI.config.featureReviewSummary',
self::BulkGeneration => 'FroshAI.config.featureBulkGeneration',
self::ImageGeneration => 'FroshAI.config.featureImageGeneration',
self::ImageEdit => 'FroshAI.config.featureImageEdit',
};
Expand All @@ -40,6 +42,7 @@ public function label(): string
self::CategoryDescription => 'Category description',
self::ManufacturerDescription => 'Manufacturer description',
self::ReviewSummary => 'Review summary',
self::BulkGeneration => 'Bulk generation',
self::ImageGeneration => 'Image generation',
self::ImageEdit => 'Image editing',
};
Expand Down
Loading
Loading