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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user