Web app: new entities (Image, RenderedAsset, SharedImage, Token, DeviceImageHistory), enums, repositories, controllers, message handlers, migrations, tests, frontend upload/library/sticker UI, Vue components. Firmware: EPD background screen binaries + gen scripts, setup_bg header. Infra: ddev config, test bundle, gitignore coverage dir. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\DeviceImageHistory;
|
||||
use App\Entity\Image;
|
||||
use App\Entity\User;
|
||||
use App\Repository\DeviceRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -42,10 +44,29 @@ class DeviceService
|
||||
return $device;
|
||||
}
|
||||
|
||||
/** Remove all image approvals and rendered assets associated with this device. */
|
||||
/** Remove all image approvals and display history for this device. */
|
||||
private function purgeDeviceHistory(Device $device): void
|
||||
{
|
||||
// Stub: will cascade-delete via ORM relationships once Image/Approval entities are added in Epic 3.
|
||||
// Doctrine cascade on the Device→Approval relationship handles this automatically.
|
||||
// Remove device from every image's approval list
|
||||
$images = $this->em->createQueryBuilder()
|
||||
->select('i')
|
||||
->from(Image::class, 'i')
|
||||
->join('i.approvedDevices', 'd')
|
||||
->where('d = :device')
|
||||
->setParameter('device', $device)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
foreach ($images as $image) {
|
||||
$image->revokeForDevice($device);
|
||||
}
|
||||
|
||||
// Purge display history (no ORM cascade fires on ownership transfer)
|
||||
$this->em->createQueryBuilder()
|
||||
->delete(DeviceImageHistory::class, 'h')
|
||||
->where('h.device = :device')
|
||||
->setParameter('device', $device)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Device;
|
||||
use App\Entity\DeviceImageHistory;
|
||||
use App\Entity\Image;
|
||||
use App\Enum\RenderStatus;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class RotationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Select the next image for the device, record history, update currentImage.
|
||||
* Returns null if no ready images exist in the pool.
|
||||
*/
|
||||
public function advance(Device $device): ?Image
|
||||
{
|
||||
$pool = $this->readyPool($device);
|
||||
|
||||
if (empty($pool)) {
|
||||
$this->logger->info('rotation.no_ready_images', ['device_id' => $device->getId()]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$window = min($device->getUniquenessWindow(), count($pool));
|
||||
$recentIds = $this->recentImageIds($device, $window);
|
||||
|
||||
$candidates = array_values(array_filter(
|
||||
$pool,
|
||||
static fn(Image $i) => !in_array($i->getId(), $recentIds, true),
|
||||
));
|
||||
|
||||
if (empty($candidates)) {
|
||||
$candidates = $pool;
|
||||
}
|
||||
|
||||
usort($candidates, static fn(Image $a, Image $b) => $a->getUploadedAt() <=> $b->getUploadedAt());
|
||||
|
||||
$image = $candidates[0];
|
||||
|
||||
$this->em->persist(new DeviceImageHistory($device, $image));
|
||||
$device->setCurrentImage($image);
|
||||
$this->em->flush();
|
||||
|
||||
$this->logger->info('rotation.advanced', [
|
||||
'device_id' => $device->getId(),
|
||||
'image_id' => $image->getId(),
|
||||
'pool_size' => count($pool),
|
||||
'recent_ids' => $recentIds,
|
||||
]);
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/** @return Image[] */
|
||||
private function readyPool(Device $device): array
|
||||
{
|
||||
return $this->em->createQueryBuilder()
|
||||
->select('i')
|
||||
->from(Image::class, 'i')
|
||||
->join('i.approvedDevices', 'd')
|
||||
->join('i.renderedAssets', 'ra')
|
||||
->where('d = :device')
|
||||
->andWhere('i.deletedAt IS NULL')
|
||||
->andWhere('ra.deviceModel = :model')
|
||||
->andWhere('ra.orientation = :orientation')
|
||||
->andWhere('ra.status = :status')
|
||||
->setParameter('device', $device)
|
||||
->setParameter('model', $device->getModel())
|
||||
->setParameter('orientation', $device->getOrientation())
|
||||
->setParameter('status', RenderStatus::Ready)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private function recentImageIds(Device $device, int $limit): array
|
||||
{
|
||||
$rows = $this->em->createQueryBuilder()
|
||||
->select('IDENTITY(h.image) AS image_id')
|
||||
->from(DeviceImageHistory::class, 'h')
|
||||
->where('h.device = :device')
|
||||
->setParameter('device', $device)
|
||||
->orderBy('h.servedAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getScalarResult();
|
||||
|
||||
return array_column($rows, 'image_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Image;
|
||||
use App\Entity\SharedImage;
|
||||
use App\Entity\User;
|
||||
use App\Enum\TokenType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
|
||||
class ShareService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly MailerInterface $mailer,
|
||||
#[Autowire('%env(int:SHARE_TOKEN_TTL_DAYS)%')]
|
||||
private readonly int $tokenTtlDays,
|
||||
#[Autowire('%env(MAILER_SENDER)%')]
|
||||
private readonly string $mailerSender,
|
||||
) {}
|
||||
|
||||
public function share(Image $image, User $sharer, string $recipientEmail): SharedImage
|
||||
{
|
||||
$recipient = $this->em->getRepository(User::class)->findOneBy(['email' => $recipientEmail]);
|
||||
|
||||
if ($recipient === null) {
|
||||
throw new \InvalidArgumentException('Recipient not found. They must have an account to receive shared photos.');
|
||||
}
|
||||
|
||||
if ($recipient->getId() === $sharer->getId()) {
|
||||
throw new \InvalidArgumentException('You cannot share a photo with yourself.');
|
||||
}
|
||||
|
||||
// Idempotent: return existing pending share if one already exists
|
||||
$existing = $this->em->getRepository(SharedImage::class)->findOneBy([
|
||||
'sourceImage' => $image,
|
||||
'recipientUser' => $recipient,
|
||||
]);
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$shared = new SharedImage($image, $recipient, $sharer);
|
||||
$this->em->persist($shared);
|
||||
|
||||
$approveToken = $this->tokenService->issue(TokenType::ShareApprove, $image, $recipient, $recipientEmail, $this->tokenTtlDays);
|
||||
$declineToken = $this->tokenService->issue(TokenType::ShareDecline, $image, $recipient, $recipientEmail, $this->tokenTtlDays);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$email = (new TemplatedEmail())
|
||||
->from($this->mailerSender)
|
||||
->to($recipientEmail)
|
||||
->subject($sharer->getEmail() . ' shared a photo with you')
|
||||
->htmlTemplate('emails/share_notification.html.twig')
|
||||
->textTemplate('emails/share_notification.txt.twig')
|
||||
->context([
|
||||
'sharer' => $sharer,
|
||||
'image' => $image,
|
||||
'approveToken' => $approveToken,
|
||||
'declineToken' => $declineToken,
|
||||
]);
|
||||
|
||||
$this->mailer->send($email);
|
||||
|
||||
return $shared;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Image;
|
||||
use App\Entity\Token;
|
||||
use App\Entity\User;
|
||||
use App\Enum\TokenType;
|
||||
use App\Repository\TokenRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class TokenService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TokenRepository $tokenRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function issue(
|
||||
TokenType $type,
|
||||
Image $image,
|
||||
?User $recipient,
|
||||
?string $recipientEmail,
|
||||
int $ttlDays,
|
||||
): Token {
|
||||
$token = new Token($type, $image, $recipient, $recipientEmail, $ttlDays);
|
||||
$this->em->persist($token);
|
||||
return $token;
|
||||
}
|
||||
|
||||
/** @throws \RuntimeException if token is not found, already used, or expired */
|
||||
public function consume(string $uuid, TokenType $type): Token
|
||||
{
|
||||
$token = $this->tokenRepository->findValidToken($uuid, $type);
|
||||
if (!$token) {
|
||||
throw new \RuntimeException('Token not found, already used, or expired.');
|
||||
}
|
||||
$token->consume();
|
||||
$this->em->flush();
|
||||
return $token;
|
||||
}
|
||||
|
||||
/** Returns a valid token without consuming it, or null. */
|
||||
public function findValid(string $uuid, TokenType $type): ?Token
|
||||
{
|
||||
return $this->tokenRepository->findValidToken($uuid, $type);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user