feat: add TourProgressController and related entities for user tour progress tracking

- Implemented TourProgressController to handle API endpoints for tracking guided tours seen by users.
- Created UserTourProgress entity to store the highest version of tours seen by each user.
- Developed UserTourProgressRepository for database interactions related to user tour progress.
- Introduced TourProgressService to manage business logic for marking tours as seen and retrieving seen maps.
- Added comprehensive tests for API endpoints and entity behavior to ensure functionality and data integrity.
This commit is contained in:
hamed
2026-08-10 09:34:14 +03:30
parent 7716b40f6a
commit 20c8eaaad9
27 changed files with 16088 additions and 477 deletions
+1
View File
@@ -44,6 +44,7 @@ final class GlobalTables
// هویت — یک شخص می‌تواند در چند محیط حضور داشته باشد
\App\Auth\Entity\User::class => 'هویت سراسری؛ رابطهٔ بیمار با محیط از patient_records می‌آید',
\App\UserProfile\Entity\UserProfile::class => 'پروفایل شخص، نه دادهٔ محیط',
\App\UserProfile\Entity\UserTourProgress::class => 'راهنمای دیده‌شدهٔ پنل به شخص وابسته است؛ کاربر با تعویض محیط دوباره تور نمی‌بیند',
\App\Auth\Entity\PreRegistration::class => 'پیش‌ثبت‌نام، هنوز به هیچ محیطی وصل نیست',
\App\Auth\Entity\UserActiveContext::class => 'خودش تعیین‌کنندهٔ محیط است؛ فیلتر کردنش حلقه می‌سازد',
@@ -0,0 +1,73 @@
<?php
namespace App\UserProfile\Controller;
use App\Auth\Entity\User;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\UserProfile\Service\TourProgressService;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'User Profile')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class TourProgressController extends BaseController
{
public function __construct(
private readonly TourProgressService $service,
) {}
#[OA\Get(
path: '/api/v1/my/tours',
summary: 'Guided tours the current user has already been through',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(response: 200, description: 'Map of tour id to the seen version'),
new OA\Response(response: 401, description: 'Not authenticated'),
]
)]
#[Route('/api/v1/my/tours', methods: ['GET'])]
public function seen(#[CurrentUser] User $user): JsonResponse
{
// An object even when empty, so the client never has to tell [] from {}.
return $this->success(['seen' => (object) $this->service->seenMap($user)]);
}
#[OA\Post(
path: '/api/v1/my/tours/{tourId}/seen',
summary: 'Record that the current user has been through a guided tour',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['version'],
properties: [new OA\Property(property: 'version', type: 'integer', minimum: 1)]
)
),
responses: [
new OA\Response(response: 200, description: 'Progress stored'),
new OA\Response(response: 401, description: 'Not authenticated'),
new OA\Response(response: 422, description: 'Invalid tour id or version'),
]
)]
#[Route('/api/v1/my/tours/{tourId}/seen', methods: ['POST'], requirements: ['tourId' => '[a-z0-9-]{1,64}'])]
public function markSeen(string $tourId, Request $request, #[CurrentUser] User $user): JsonResponse
{
$payload = json_decode($request->getContent() ?: '{}', true);
$version = is_array($payload) ? ($payload['version'] ?? null) : null;
// Version drives whether a rewritten tour is shown again, so a bad value must
// not be silently coerced to 0 and hide the tour for good.
if (!is_int($version) || $version < 1) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نسخهٔ راهنما باید عددی بزرگ‌تر از صفر باشد', 422, 'version');
}
$this->service->markSeen($user, $tourId, $version);
return $this->success(['tourId' => $tourId, 'version' => $version]);
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\UserProfile\Entity;
use App\Auth\Entity\User;
use App\UserProfile\Repository\UserTourProgressRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* The newest version of an in-app guided tour a user has already been through.
*
* One row per (user, tour). Bumping a tour's version in the frontend registry makes
* the stored version stale, which is how a rewritten tour gets shown again once.
*/
#[ORM\Entity(repositoryClass: UserTourProgressRepository::class)]
#[ORM\Table(name: 'user_tour_progress')]
#[ORM\UniqueConstraint(name: 'uniq_user_tour', columns: ['user_id', 'tour_id'])]
class UserTourProgress
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private User $user;
#[ORM\Column(name: 'tour_id', type: 'string', length: 64)]
private string $tourId;
#[ORM\Column(type: 'integer')]
private int $version;
#[ORM\Column(name: 'seen_at', type: 'integer')]
private int $seenAt;
public function __construct(User $user, string $tourId, int $version)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->tourId = $tourId;
$this->version = $version;
$this->seenAt = time();
}
public function getId(): ?int
{
return $this->id;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getUser(): User
{
return $this->user;
}
public function getTourId(): string
{
return $this->tourId;
}
public function getVersion(): int
{
return $this->version;
}
public function getSeenAt(): int
{
return $this->seenAt;
}
/** Only ever moves forward: replaying an older tour must not hide a newer one. */
public function markSeen(int $version): void
{
if ($version > $this->version) {
$this->version = $version;
}
$this->seenAt = time();
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\UserProfile\Repository;
use App\Auth\Entity\User;
use App\UserProfile\Entity\UserTourProgress;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class UserTourProgressRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserTourProgress::class);
}
public function findOneForUser(User $user, string $tourId): ?UserTourProgress
{
return $this->findOneBy(['user' => $user, 'tourId' => $tourId]);
}
/**
* @return array<string, int> tour id => highest version the user has seen
*/
public function seenMap(User $user): array
{
$rows = $this->createQueryBuilder('p')
->select('p.tourId AS tourId', 'p.version AS version')
->where('p.user = :user')
->setParameter('user', $user)
->getQuery()
->getArrayResult();
return array_column($rows, 'version', 'tourId');
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\UserProfile\Service;
use App\Auth\Entity\User;
use App\UserProfile\Entity\UserTourProgress;
use App\UserProfile\Repository\UserTourProgressRepository;
use Doctrine\ORM\EntityManagerInterface;
/**
* Reads and records which admin panel guided tours a user has already been through.
*/
class TourProgressService
{
public function __construct(
private readonly UserTourProgressRepository $repository,
private readonly EntityManagerInterface $em,
) {}
/**
* @return array<string, int> tour id => highest version the user has seen
*/
public function seenMap(User $user): array
{
return $this->repository->seenMap($user);
}
public function markSeen(User $user, string $tourId, int $version): void
{
$progress = $this->repository->findOneForUser($user, $tourId);
if ($progress === null) {
$this->em->persist(new UserTourProgress($user, $tourId, $version));
$this->em->flush();
return;
}
$progress->markSeen($version);
$this->em->flush();
}
}