From dce31ac07c64aee39355f01da2abd06af1cf1bc3 Mon Sep 17 00:00:00 2001 From: Bohdan Shulha Date: Fri, 13 Sep 2024 16:47:56 +0200 Subject: [PATCH] feat: #88 add the quotas page, ensure quotas for deployments --- _ide_helper_actions.php | 12 ++ app/Actions/Services/CreateService.php | 65 ++++++ app/Actions/Services/StartDeployment.php | 20 +- app/Http/Controllers/ServiceController.php | 44 +--- app/Http/Controllers/TeamQuotasController.php | 38 ++++ app/Models/PricingPlan/ItemQuota.php | 10 +- app/Models/PricingPlan/Plan.php | 17 ++ app/Models/PricingPlan/UsageQuotas.php | 2 + app/Models/Team.php | 70 +++++-- app/Providers/AppServiceProvider.php | 3 +- config/billing.php | 42 +++- resources/js/Components/Banner.vue | 51 ++++- resources/js/Layouts/AppLayout.vue | 26 --- resources/js/Pages/Services/Index.vue | 29 ++- resources/js/Pages/Services/Show.vue | 3 - resources/js/Pages/Teams/Quotas.vue | 194 ++++++++++++++++++ resources/js/Pages/Teams/TeamsLayout.vue | 131 ++++++++---- resources/js/types/quotas.ts | 14 ++ routes/web.php | 9 +- 19 files changed, 616 insertions(+), 164 deletions(-) create mode 100644 app/Actions/Services/CreateService.php create mode 100644 app/Http/Controllers/TeamQuotasController.php create mode 100644 app/Models/PricingPlan/Plan.php create mode 100644 resources/js/Pages/Teams/Quotas.vue create mode 100644 resources/js/types/quotas.ts diff --git a/_ide_helper_actions.php b/_ide_helper_actions.php index 78528c8..714bc2b 100644 --- a/_ide_helper_actions.php +++ b/_ide_helper_actions.php @@ -17,6 +17,18 @@ class InitCluster {} namespace App\Actions\Services; +/** + * @method static \Lorisleiva\Actions\Decorators\JobDecorator|\Lorisleiva\Actions\Decorators\UniqueJobDecorator makeJob(\App\Models\User $user, \App\Models\Team $team, string $name, \App\Models\DeploymentData $deploymentData) + * @method static \Lorisleiva\Actions\Decorators\UniqueJobDecorator makeUniqueJob(\App\Models\User $user, \App\Models\Team $team, string $name, \App\Models\DeploymentData $deploymentData) + * @method static \Illuminate\Foundation\Bus\PendingDispatch dispatch(\App\Models\User $user, \App\Models\Team $team, string $name, \App\Models\DeploymentData $deploymentData) + * @method static \Illuminate\Foundation\Bus\PendingDispatch|\Illuminate\Support\Fluent dispatchIf(bool $boolean, \App\Models\User $user, \App\Models\Team $team, string $name, \App\Models\DeploymentData $deploymentData) + * @method static \Illuminate\Foundation\Bus\PendingDispatch|\Illuminate\Support\Fluent dispatchUnless(bool $boolean, \App\Models\User $user, \App\Models\Team $team, string $name, \App\Models\DeploymentData $deploymentData) + * @method static dispatchSync(\App\Models\User $user, \App\Models\Team $team, string $name, \App\Models\DeploymentData $deploymentData) + * @method static dispatchNow(\App\Models\User $user, \App\Models\Team $team, string $name, \App\Models\DeploymentData $deploymentData) + * @method static dispatchAfterResponse(\App\Models\User $user, \App\Models\Team $team, string $name, \App\Models\DeploymentData $deploymentData) + * @method static \App\Models\Service run(\App\Models\User $user, \App\Models\Team $team, string $name, \App\Models\DeploymentData $deploymentData) + */ +class CreateService {} /** * @method static \Lorisleiva\Actions\Decorators\JobDecorator|\Lorisleiva\Actions\Decorators\UniqueJobDecorator makeJob(\App\Models\User $user, \App\Models\Service $service, \App\Models\DeploymentData $deploymentData) * @method static \Lorisleiva\Actions\Decorators\UniqueJobDecorator makeUniqueJob(\App\Models\User $user, \App\Models\Service $service, \App\Models\DeploymentData $deploymentData) diff --git a/app/Actions/Services/CreateService.php b/app/Actions/Services/CreateService.php new file mode 100644 index 0000000..29c612b --- /dev/null +++ b/app/Actions/Services/CreateService.php @@ -0,0 +1,65 @@ + ['required', 'string', 'max:255'], + 'deploymentData' => ['required', 'array'], + ]; + } + + public function authorize(ActionRequest $request): bool + { + return true; // Authorization will be handled by policies + } + + public function handle(User $user, Team $team, string $name, DeploymentData $deploymentData): Service + { + return DB::transaction(function () use ($user, $team, $name, $deploymentData) { + $swarm = $team->swarms()->firstOrFail(); + + $service = new Service([ + 'name' => $name, + 'team_id' => $team->id, + 'swarm_id' => $swarm->id, + ]); + + $service->save(); + + StartDeployment::run($user, $service, $deploymentData); + + return $service; + }); + } + + public function asController(ActionRequest $request) + { + $user = $request->user(); + $team = $user->currentTeam; + $quotas = $team->quotas(); + + $quotas->services->ensureQuota(); + + $validated = $request->validated(); + $deploymentData = DeploymentData::validateAndCreate($validated['deploymentData']); + + $service = $this->handle($user, $team, $validated['name'], $deploymentData); + + return redirect()->route('services.deployments', $service) + ->with('success', 'Service created and deployment scheduled successfully.'); + } +} diff --git a/app/Actions/Services/StartDeployment.php b/app/Actions/Services/StartDeployment.php index 005691c..4e43af2 100644 --- a/app/Actions/Services/StartDeployment.php +++ b/app/Actions/Services/StartDeployment.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\DB; use Lorisleiva\Actions\ActionRequest; use Lorisleiva\Actions\Concerns\AsAction; +use Symfony\Component\HttpFoundation\Response; class StartDeployment { @@ -18,17 +19,12 @@ class StartDeployment public function rules(): array { - return [ - 'service_id' => ['required', 'exists:services,id'], - 'deployment_data' => ['required', DeploymentData::class], - ]; + return DeploymentData::getValidationRules([]); } public function authorize(ActionRequest $request): bool { - $service = Service::with('team')->findOrFail($request->service_id); - - return $request->user()->belongsToTeam($service->team); + return true; } public function handle(User $user, Service $service, DeploymentData $deploymentData): Deployment @@ -52,11 +48,17 @@ public function handle(User $user, Service $service, DeploymentData $deploymentD }); } - public function asController(ActionRequest $request) + public function asController(Service $service, ActionRequest $request): Response { - $service = Service::findOrFail($request->service_id); $deploymentData = DeploymentData::make($request->validated()); + $team = $service->team; + $quotas = $team->quotas(); + + $quotas->deployments->ensureQuota(); + $this->handle($request->user(), $service, $deploymentData); + + return to_route('services.deployments', $service); } } diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index bfab229..1be7e35 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -2,8 +2,6 @@ namespace App\Http\Controllers; -use App\Actions\Services\StartDeployment; -use App\Http\Requests\StoreServiceRequest; use App\Http\Requests\UpdateServiceRequest; use App\Models\DeploymentData; use App\Models\Service; @@ -26,7 +24,16 @@ public function index() $swarmExists = Swarm::exists(); - return Inertia::render('Services/Index', ['services' => $services, 'swarmExists' => $swarmExists]); + // Check if the quota is reached using ItemQuota + $team = auth()->user()->currentTeam; + $serviceQuota = $team->quotas()->services; + $quotaReached = $serviceQuota->quotaReached(); + + return Inertia::render('Services/Index', [ + 'services' => $services, + 'swarmExists' => $swarmExists, + 'quotaReached' => $quotaReached, + ]); } /** @@ -55,30 +62,6 @@ public function create() ]); } - /** - * Store a newly created resource in storage. - */ - public function store(StoreServiceRequest $request) - { - $deploymentData = DeploymentData::validateAndCreate($request->get('deploymentData')); - - $team = auth()->user()->currentTeam; - $swarm = $team->swarms()->firstOrFail(); - - $service = Service::make($request->validated()); - $service->team_id = $team->id; - $service->swarm_id = $swarm->id; - - DB::transaction(function () use ($service, $deploymentData) { - $service->save(); - - StartDeployment::run(auth()->user(), $service, $deploymentData); - }); - - return to_route('services.deployments', $service) - ->with('success', 'Service created and deployment scheduled successfully.'); - } - /** * Display the specified resource. */ @@ -113,13 +96,6 @@ public function deployments(Service $service) return Inertia::render('Services/Deployments', ['service' => $service]); } - public function deploy(Service $service, DeploymentData $deploymentData) - { - StartDeployment::run(auth()->user(), $service, $deploymentData); - - return to_route('services.deployments', $service); - } - /** * Show the form for editing the specified resource. */ diff --git a/app/Http/Controllers/TeamQuotasController.php b/app/Http/Controllers/TeamQuotasController.php new file mode 100644 index 0000000..0099080 --- /dev/null +++ b/app/Http/Controllers/TeamQuotasController.php @@ -0,0 +1,38 @@ +quotas(); + $isOnTrial = $team->onTrial(); + $quotaReached = false; + + $formattedQuotas = []; + foreach ($quotas as $key => $quota) { + $formattedQuotas[$key] = [ + 'currentUsage' => $quota->currentUsage(), + 'maxUsage' => $quota->maxUsage, + 'isSoftQuota' => $quota->isSoftQuota, + 'almostQuotaReached' => $quota->almostQuotaReached(), + 'isIntrinsic' => $key === 'swarms', // Mark swarms as intrinsic + ]; + + if ($quota->quotaReached() && ! $formattedQuotas[$key]['isIntrinsic']) { + $quotaReached = true; + } + } + + return Inertia::render('Teams/Quotas', [ + 'team' => $team->only(['id', 'name']), + 'quotas' => $formattedQuotas, + 'isOnTrial' => $isOnTrial, + 'quotaReached' => $quotaReached, + ]); + } +} diff --git a/app/Models/PricingPlan/ItemQuota.php b/app/Models/PricingPlan/ItemQuota.php index c6c0fdf..4ea9197 100644 --- a/app/Models/PricingPlan/ItemQuota.php +++ b/app/Models/PricingPlan/ItemQuota.php @@ -3,19 +3,23 @@ namespace App\Models\PricingPlan; use Closure; -use RuntimeException; +use Illuminate\Validation\ValidationException; class ItemQuota { public function __construct( + public string $name, public int $maxUsage, protected Closure $getCurrentUsage, + public bool $isSoftQuota = false ) {} public function ensureQuota(): void { if ($this->quotaReached()) { - throw new RuntimeException('Invalid State - The team is at its node limit'); + throw ValidationException::withMessages([ + 'quota' => "The maximum limit of {$this->maxUsage} {$this->name} has been reached.", + ]); } } @@ -29,7 +33,7 @@ public function quotaReached(): bool return $this->currentUsage() >= $this->maxUsage; } - protected function currentUsage(): int + public function currentUsage(): int { return call_user_func($this->getCurrentUsage); } diff --git a/app/Models/PricingPlan/Plan.php b/app/Models/PricingPlan/Plan.php new file mode 100644 index 0000000..d85cf37 --- /dev/null +++ b/app/Models/PricingPlan/Plan.php @@ -0,0 +1,17 @@ +hasMany(Service::class); } + public function deployments(): HasMany + { + return $this->hasMany(Deployment::class); + } + /** * Get the name of the team. */ @@ -131,36 +137,60 @@ public function routeNotificationForMail(Notification $notification): array|stri return [$this->customer->email => $this->customer->name]; } - public function quotas(): UsageQuotas + public function currentPlan(): Plan { + $plans = config('billing.paddle.plans'); + $trialPlan = config('billing.paddle.trialPlan'); + $selfHostedPlan = config('billing.paddle.selfHostedPlan'); + $subscription = $this->subscription(); + if (! config('billing.enabled')) { - return new UsageQuotas( - new ItemQuota(PHP_INT_MAX, fn () => 0), - new ItemQuota(PHP_INT_MAX, fn () => 0), - ); + return Plan::from($selfHostedPlan); } - if ($this->subscription() === null || ! $this->subscription()->valid()) { - return new UsageQuotas( - new ItemQuota(0, fn () => 0), - new ItemQuota(0, fn () => 0), - ); + if ($subscription->onTrial() || ! $this->hasValidSubscription()) { + return Plan::from($trialPlan); } - foreach (config('billing.paddle.plans') as $plan) { - if ($this->subscription()->hasProduct($plan['product_id'])) { - $quotas = $plan['quotas']; - - return new UsageQuotas( - new ItemQuota($quotas['nodes'], fn () => $this->nodes()->count()), - new ItemQuota($quotas['swarms'], fn () => $this->swarms()->count()), - ); + foreach ($plans as $plan) { + if ($subscription->hasProduct($plan['product_id'])) { + return Plan::from($plan); } } + // If no matching plan found, return the trial plan as default + return Plan::from($trialPlan); + } + + public function quotas(): UsageQuotas + { + $plan = $this->currentPlan(); + return new UsageQuotas( - new ItemQuota(0, fn () => 0), - new ItemQuota(0, fn () => 0), + new ItemQuota( + name: 'Nodes', + maxUsage: $plan->quotas['nodes']['limit'], + getCurrentUsage: fn () => $this->nodes()->count(), + isSoftQuota: $plan->quotas['nodes']['soft'] + ), + new ItemQuota( + name: 'Swarms', + maxUsage: $plan->quotas['swarms']['limit'], + getCurrentUsage: fn () => $this->swarms()->count(), + isSoftQuota: $plan->quotas['swarms']['soft'] + ), + new ItemQuota( + name: 'Services', + maxUsage: $plan->quotas['services']['limit'], + getCurrentUsage: fn () => $this->services()->count(), + isSoftQuota: $plan->quotas['services']['soft'] + ), + new ItemQuota( + name: 'Deployments', + maxUsage: $plan->quotas['deployments']['limit'], + getCurrentUsage: fn () => $this->deployments()->count(), + isSoftQuota: $plan->quotas['deployments']['soft'] + ) ); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index eb21822..eb81c50 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ use App\Listeners\ScheduleTrialEndNotifications; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Lorisleiva\Actions\Facades\Actions; class AppServiceProvider extends ServiceProvider { @@ -13,7 +14,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + Actions::registerRoutes(); } /** diff --git a/config/billing.php b/config/billing.php index 7592489..3005dcf 100644 --- a/config/billing.php +++ b/config/billing.php @@ -12,10 +12,10 @@ 'product_id' => env('PADDLE_PLAN_HOBBY_PRODUCT_ID'), 'price_id' => env('PADDLE_PLAN_HOBBY_PRICE_ID'), 'quotas' => [ - 'nodes' => 1, - 'swarms' => 1, - 'services' => 20, - 'deployments' => 100, + 'nodes' => ['limit' => 1, 'soft' => false], + 'swarms' => ['limit' => 1, 'soft' => false], + 'services' => ['limit' => 20, 'soft' => true], + 'deployments' => ['limit' => 100, 'soft' => true], ], ], [ @@ -25,12 +25,38 @@ 'product_id' => env('PADDLE_PLAN_STARTUP_PRODUCT_ID'), 'price_id' => env('PADDLE_PLAN_STARTUP_PRICE_ID'), 'quotas' => [ - 'nodes' => 9, - 'swarms' => 2, - 'services' => 20, - 'deployments' => 100, + 'nodes' => ['limit' => 5, 'soft' => true], + 'swarms' => ['limit' => 1, 'soft' => false], + 'services' => ['limit' => 10, 'soft' => true], + 'deployments' => ['limit' => 100, 'soft' => true], ], ], ], + 'trialPlan' => [ + 'name' => 'Trial', + 'price' => 0, + 'description' => 'Try our service for free for a limited time', + 'product_id' => null, + 'price_id' => null, + 'quotas' => [ + 'nodes' => ['limit' => 1, 'soft' => false], + 'swarms' => ['limit' => 1, 'soft' => false], + 'services' => ['limit' => 3, 'soft' => false], + 'deployments' => ['limit' => 20, 'soft' => false], + ], + ], + 'selfHostedPlan' => [ + 'name' => 'Self-Hosted', + 'price' => 0, + 'description' => 'For users running their own instance of the service', + 'product_id' => null, + 'price_id' => null, + 'quotas' => [ + 'nodes' => ['limit' => 1000, 'soft' => true], + 'swarms' => ['limit' => 1, 'soft' => false], + 'services' => ['limit' => 5000, 'soft' => true], + 'deployments' => ['limit' => 100000, 'soft' => true], + ], + ], ], ]; diff --git a/resources/js/Components/Banner.vue b/resources/js/Components/Banner.vue index 0cdb686..3f35207 100644 --- a/resources/js/Components/Banner.vue +++ b/resources/js/Components/Banner.vue @@ -1,16 +1,42 @@ @@ -84,9 +110,16 @@ watchEffect(async () => { -

- {{ message }} -

+
+ {{ message }} + + {{ link.text }} + +
diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index 3d3fe8b..1986d73 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -7,7 +7,6 @@ import Dropdown from "@/Components/Dropdown.vue"; import DropdownLink from "@/Components/DropdownLink.vue"; import NavLink from "@/Components/NavLink.vue"; import ResponsiveNavLink from "@/Components/ResponsiveNavLink.vue"; -import PrimaryButton from "@/Components/PrimaryButton.vue"; import dayjs from "dayjs"; defineProps({ @@ -601,31 +600,6 @@ const logout = () => { class="flex flex-wrap -mb-px text-sm font-medium text-center text-gray-500 dark:text-gray-400" > - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/resources/js/Pages/Services/Index.vue b/resources/js/Pages/Services/Index.vue index ac0eb79..8b943dd 100644 --- a/resources/js/Pages/Services/Index.vue +++ b/resources/js/Pages/Services/Index.vue @@ -10,6 +10,7 @@ import { FwbTooltip } from "flowbite-vue"; const props = defineProps({ services: Array, swarmExists: Boolean, + quotaReached: Boolean, }); @@ -25,22 +26,34 @@ const props = defineProps({ diff --git a/resources/js/Pages/Services/Show.vue b/resources/js/Pages/Services/Show.vue index 2b86905..0f0016a 100644 --- a/resources/js/Pages/Services/Show.vue +++ b/resources/js/Pages/Services/Show.vue @@ -6,7 +6,6 @@ import DeploymentData from "@/Pages/Services/Partials/DeploymentData.vue"; import ServiceDetailsForm from "@/Pages/Services/Partials/ServiceDetailsForm.vue"; import FormSection from "@/Components/FormSection.vue"; import SectionBorder from "@/Components/SectionBorder.vue"; -import { ref } from "vue"; import DeleteResourceSection from "@/Components/DeleteResourceSection.vue"; const props = defineProps({ @@ -37,8 +36,6 @@ const deletionForm = useForm({ const destroyService = () => { return deletionForm.delete(route("services.destroy", props.service)); }; - -const serviceDeleteInput = ref(null);