feat: implement staff management and subscription system

- Added StaffController for managing clinic staff, including listing, creating, updating, and toggling staff status.
- Created ClinicStaff entity and repository for staff data handling.
- Developed SubscriptionController to manage subscription plans and periods, including trial subscriptions.
- Introduced SubscriptionPlan, SubscriptionPeriod, and ClinicSubscription entities for subscription management.
- Implemented SubscriptionService for handling subscription logic, including trial activation and subscription creation from payments.
- Added necessary repositories for subscription entities to facilitate data access and manipulation.
This commit is contained in:
hamed
2026-06-14 22:10:28 +03:30
parent dcd631f503
commit b0244f28f5
53 changed files with 4434 additions and 40 deletions
@@ -0,0 +1,255 @@
<?php
namespace App\Subscription\Controller;
use App\Auth\Entity\User;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Subscription\Entity\SubscriptionPeriod;
use App\Subscription\Entity\SubscriptionPlan;
use App\Subscription\Repository\SubscriptionPlanRepository;
use App\Subscription\Repository\SubscriptionPeriodRepository;
use App\Subscription\Repository\ClinicSubscriptionRepository;
use App\Subscription\Service\SubscriptionService;
use Doctrine\ORM\EntityManagerInterface;
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;
class SubscriptionController extends BaseController
{
public function __construct(
private readonly SubscriptionService $subscriptionService,
private readonly SubscriptionPlanRepository $planRepo,
private readonly SubscriptionPeriodRepository $periodRepo,
private readonly ClinicSubscriptionRepository $subscriptionRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly EntityManagerInterface $em,
) {}
// ── Public ──────────────────────────────────────────────────────────────
#[Route('/api/v1/subscription/plans', methods: ['GET'])]
public function plans(): JsonResponse
{
$plans = $this->planRepo->findAllActive();
return $this->success(array_map(
fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true),
$plans
));
}
// ── Authenticated ────────────────────────────────────────────────────────
#[Route('/api/v1/subscription/my', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function my(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$subscription = $this->subscriptionService->getActiveSubscription($entityType, $entityId);
$usedTrial = $this->subscriptionService->hasUsedTrial($entityType, $entityId);
return $this->success([
'subscription' => $subscription?->toArray(),
'used_trial' => $usedTrial,
]);
}
#[Route('/api/v1/subscription/trial', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function trial(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
try {
$subscription = $this->subscriptionService->activateTrial($entityType, $entityId);
} catch (AppException $e) {
return $this->error($e->getErrorCode(), $e->getMessage(), $e->getHttpStatus());
}
return $this->success($subscription->toArray(), 201);
}
// ── Admin ────────────────────────────────────────────────────────────────
#[Route('/api/v1/admin/subscription/plans', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminPlans(): JsonResponse
{
$plans = $this->planRepo->findAllActive();
return $this->paginated(
array_map(fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true), $plans),
count($plans),
1,
100
);
}
#[Route('/api/v1/admin/subscription/plan', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminCreatePlan(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
if ($name === '' || !isset($data['level'])) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name و level الزامی هستند', 422);
}
$plan = new SubscriptionPlan(
$name,
(int) $data['level'],
(int) ($data['max_secretaries'] ?? 1),
$data['features'] ?? []
);
$this->planRepo->save($plan);
return $this->success($plan->toArray(), 201);
}
#[Route('/api/v1/admin/subscription/plan/{uuid}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function adminUpdatePlan(string $uuid, Request $request): JsonResponse
{
$plan = $this->planRepo->findByUuid($uuid);
if ($plan === null) {
return $this->error(ErrorCodes::ERR_SUBSCRIPTION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SUBSCRIPTION_NOT_FOUND), 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name'])) { $plan->setName($data['name']); }
if (isset($data['level'])) { $plan->setLevel((int) $data['level']); }
if (isset($data['max_secretaries'])) { $plan->setMaxSecretaries((int) $data['max_secretaries']); }
if (isset($data['features'])) { $plan->setFeatures($data['features']); }
if (isset($data['active'])) { $plan->setActive((bool) $data['active']); }
$this->planRepo->save($plan);
return $this->success($plan->toArray());
}
#[Route('/api/v1/admin/subscription/period', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminCreatePeriod(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$planUuid = $data['plan_uuid'] ?? '';
$plan = $this->planRepo->findByUuid($planUuid);
if ($plan === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پنل یافت نشد', 404);
}
if (empty($data['label']) || !isset($data['duration_months'])) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'label و duration_months الزامی هستند', 422);
}
$period = new SubscriptionPeriod(
$plan,
$data['label'],
(int) $data['duration_months'],
(int) ($data['price_rials'] ?? 0),
(bool) ($data['is_trial'] ?? false)
);
$period->setSortOrder((int) ($data['sort_order'] ?? 0));
$this->periodRepo->save($period);
return $this->success($period->toArray(), 201);
}
#[Route('/api/v1/admin/subscription/period/{uuid}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function adminUpdatePeriod(string $uuid, Request $request): JsonResponse
{
$period = $this->periodRepo->findByUuid($uuid);
if ($period === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['label'])) { $period->setLabel($data['label']); }
if (isset($data['duration_months'])) { $period->setDurationMonths((int) $data['duration_months']); }
if (isset($data['price_rials'])) { $period->setPriceRials((int) $data['price_rials']); }
if (isset($data['active'])) { $period->setActive((bool) $data['active']); }
if (isset($data['sort_order'])) { $period->setSortOrder((int) $data['sort_order']); }
$this->periodRepo->save($period);
return $this->success($period->toArray());
}
#[Route('/api/v1/admin/subscription/period/{uuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function adminDeletePeriod(string $uuid): JsonResponse
{
$period = $this->periodRepo->findByUuid($uuid);
if ($period === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404);
}
$period->setActive(false);
$this->periodRepo->save($period);
return $this->success(['message' => 'دوره غیرفعال شد']);
}
#[Route('/api/v1/admin/subscription/report', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminReport(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(10, (int) $request->query->get('limit', 20)));
$total = (int) $this->em->createQuery('SELECT COUNT(s.id) FROM App\Subscription\Entity\ClinicSubscription s')
->getSingleScalarResult();
$subscriptions = $this->em->createQuery('
SELECT s.uuid, s.entityType, s.entityId, s.isTrial, s.startsAt, s.expiresAt, s.createdAt,
p.name AS plan_name, p.level AS plan_level
FROM App\Subscription\Entity\ClinicSubscription s
JOIN s.plan p
ORDER BY s.id DESC
')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getArrayResult();
return $this->paginated($subscriptions, $total, $page, $limit);
}
// ── Helpers ─────────────────────────────────────────────────────────────
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
}
return ['unknown', null];
}
}