Skip to content

Commit 2bdaaf5

Browse files
committed
Polish tenant UI and fix OTA update queue flow
- Fix text contrast on the trial banner 'Review Plans' button - Fix 404 error when queuing updates by adding custom route key binding to AppVersion model - Build an interactive Alpine.js progress bar for the OTA update screen with connection polling and local storage persistence - Rename technical 'Base Queue Update' button text to 'Update Now' - Fix transient native scrollbar flash on confirmation modals by overriding dialog overflow - Fix fatal parse error in TenantUpdateHandler caused by duplicated interface declarations - Rebuild frontend assets
1 parent 4028e15 commit 2bdaaf5

File tree

126 files changed

+6217
-1244
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

126 files changed

+6217
-1244
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,8 @@ AWS_BUCKET=
6363
AWS_USE_PATH_STYLE_ENDPOINT=false
6464

6565
VITE_APP_NAME="${APP_NAME}"
66+
67+
# GitHub Releases
68+
GITHUB_REPO=stancl/tenancy
69+
GITHUB_TOKEN=
70+
# PayMongo ConfigurationPAYMONGO_PUBLIC_KEY=PAYMONGO_SECRET_KEY=PAYMONGO_WEBHOOK_SECRET=
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\TenantSubscription;
6+
use Illuminate\Console\Attributes\Description;
7+
use Illuminate\Console\Attributes\Signature;
8+
use Illuminate\Console\Command;
9+
10+
#[Signature('subscriptions:check')]
11+
#[Description('Scan all subscriptions to update trial expirations or past-due renewals automatically.')]
12+
class CheckSubscriptions extends Command
13+
{
14+
/**
15+
* Execute the console command.
16+
*/
17+
public function handle()
18+
{
19+
$this->info('Starting subscription status check...');
20+
21+
// 1. Expire trials that have passed 'trial_ends_at'
22+
$expiredTrials = TenantSubscription::where('status', 'trialing')
23+
->whereNotNull('trial_ends_at')
24+
->where('trial_ends_at', '<', now())
25+
->update([
26+
'status' => 'expired',
27+
]);
28+
29+
$this->info("Expired {$expiredTrials} trailing subscriptions.");
30+
31+
// 2. Mark active subscriptions that have passed 'current_period_end' but no payment was made as past_due.
32+
$pastDueActive = TenantSubscription::where('status', 'active')
33+
->whereNotNull('current_period_end')
34+
->where('current_period_end', '<', now()->subDays(3))
35+
->update([
36+
'status' => 'past_due',
37+
]);
38+
39+
$this->info("Marked {$pastDueActive} active subscriptions as past due.");
40+
41+
$this->info('Subscription check complete.');
42+
}
43+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\AppVersion;
6+
use App\Models\Tenant;
7+
use App\Notifications\Central\NewUpdateAvailableNotification;
8+
use App\Services\Central\GitHubReleaseService;
9+
use Carbon\Carbon;
10+
use Illuminate\Console\Attributes\Description;
11+
use Illuminate\Console\Attributes\Signature;
12+
use Illuminate\Console\Command;
13+
use Illuminate\Support\Facades\Notification;
14+
use Illuminate\Support\Str;
15+
16+
#[Signature('app:sync-releases')]
17+
#[Description('Synchronize available updates originating from the configured GitHub repository.')]
18+
class SyncAppReleasesCommand extends Command
19+
{
20+
/**
21+
* Execute the console command.
22+
*/
23+
public function handle(GitHubReleaseService $githubService)
24+
{
25+
$this->info('Starting GitHub releases synchronization...');
26+
27+
$repo = config('updates.github.repo');
28+
if (empty($repo) || $repo === 'owner/repo') {
29+
$this->error('Update repository not configured! Please set GITHUB_REPO.');
30+
31+
return Command::FAILURE;
32+
}
33+
34+
$releases = $githubService->getReleases();
35+
36+
if (empty($releases)) {
37+
$this->warn('No releases fetched or the JSON response is empty.');
38+
39+
return Command::SUCCESS;
40+
}
41+
42+
$addedCounter = 0;
43+
$skippedCounter = 0;
44+
$isFirstSync = ! AppVersion::exists();
45+
$newVersions = [];
46+
47+
// Reverse to process oldest first
48+
foreach (array_reverse($releases) as $release) {
49+
$tagName = $release['tag_name'] ?? null;
50+
if (! $tagName) {
51+
continue;
52+
}
53+
54+
$existing = AppVersion::where('version', $tagName)->first();
55+
56+
if ($existing) {
57+
$skippedCounter++;
58+
59+
continue;
60+
}
61+
62+
$appVersion = AppVersion::create([
63+
'version' => $tagName,
64+
'title' => $release['name'] ?? $tagName,
65+
'release_notes' => $release['body'] ?? '',
66+
'is_prerelease' => $release['prerelease'] ?? false,
67+
'github_release_id' => (string) ($release['id'] ?? ''),
68+
'released_at' => isset($release['published_at']) ? Carbon::parse($release['published_at']) : now(),
69+
'is_critical' => Str::contains(strtolower($release['body'] ?? ''), '[critical]'),
70+
]);
71+
72+
$addedCounter++;
73+
$newVersions[] = $appVersion;
74+
$this->line("Synced new version: {$appVersion->version}");
75+
}
76+
77+
$this->info("Sync completed: {$addedCounter} added, {$skippedCounter} skipped.");
78+
79+
if (! $isFirstSync && count($newVersions) > 0) {
80+
$this->notifyTenantsAboutNewReleases(collect($newVersions)->last());
81+
}
82+
83+
return Command::SUCCESS;
84+
}
85+
86+
protected function notifyTenantsAboutNewReleases(AppVersion $latestVersion): void
87+
{
88+
$this->info('Notifying active tenants about newly available releases...');
89+
90+
$tenants = Tenant::where('status', 'active')->get();
91+
92+
foreach ($tenants as $tenant) {
93+
if (! empty($tenant->admin_email)) {
94+
Notification::route('mail', $tenant->admin_email)
95+
->notify(new NewUpdateAvailableNotification($tenant, $latestVersion));
96+
}
97+
}
98+
}
99+
}

app/Enums/PlanFeature.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
namespace App\Enums;
4+
5+
enum PlanFeature: string
6+
{
7+
case CUSTOM_BRANDING = 'custom_branding';
8+
case ADVANCED_REPORTS = 'advanced_reports';
9+
case API_ACCESS = 'api_access';
10+
case WEBHOOK = 'webhook';
11+
case PRIORITY_SUPPORT = 'priority_support';
12+
case UNLIMITED_USERS = 'unlimited_users';
13+
case UNLIMITED_JOBS = 'unlimited_job_orders';
14+
case CUSTOM_DOMAIN = 'custom_domain';
15+
16+
public static function all(): array
17+
{
18+
return array_column(self::cases(), 'value');
19+
}
20+
}

app/Http/Controllers/Central/Admin/AdminDashboardController.php

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,16 @@
33
namespace App\Http\Controllers\Central\Admin;
44

55
use App\Http\Controllers\Controller;
6+
use App\Models\ActivityLog;
67
use App\Models\Tenant;
78
use Illuminate\View\View;
89

9-
use App\Models\ActivityLog;
10-
1110
class AdminDashboardController extends Controller
1211
{
1312
public function index(): View
1413
{
1514
$stats = [
1615
'total_tenants' => Tenant::count(),
17-
'pending_approvals' => Tenant::where('status', 'pending')->count(),
18-
'active_tenants' => Tenant::where('status', 'active')->count(),
19-
'suspended_tenants' => Tenant::where('status', 'suspended')->count(),
2016
];
2117

2218
$recent_tenants = Tenant::latest()->take(5)->get();

app/Http/Controllers/Central/Admin/PricingPlanController.php

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class PricingPlanController extends Controller
1515
*/
1616
public function index(): View
1717
{
18-
$plans = Plan::withCount(['tenants' => function($q) {
18+
$plans = Plan::withCount(['tenants' => function ($q) {
1919
$q->where('status', 'active');
2020
}])->orderBy('sort_order')->withTrashed()->get();
2121

@@ -29,10 +29,10 @@ public function store(Request $request)
2929
{
3030
$validated = $this->validatePlan($request);
3131
$validated['slug'] = Str::slug($validated['slug'] ?? $validated['name']);
32-
if (!$validated['slug']) {
33-
$validated['slug'] = Str::slug($validated['name']);
32+
if (! $validated['slug']) {
33+
$validated['slug'] = Str::slug($validated['name']);
3434
}
35-
35+
3636
$validated['currency'] = 'PHP'; // default currency
3737

3838
Plan::create($validated);
@@ -47,10 +47,10 @@ public function update(Request $request, Plan $plan)
4747
{
4848
$validated = $this->validatePlan($request, $plan->id);
4949
$validated['slug'] = Str::slug($validated['slug'] ?? $validated['name']);
50-
if (!$validated['slug']) {
51-
$validated['slug'] = Str::slug($validated['name']);
50+
if (! $validated['slug']) {
51+
$validated['slug'] = Str::slug($validated['name']);
5252
}
53-
53+
5454
$plan->update($validated);
5555

5656
return back()->with('success', 'Pricing plan updated successfully.');
@@ -65,18 +65,20 @@ public function destroy(Plan $plan)
6565
// If active tenants exist, we archive (soft delete) instead of hard delete
6666
$plan->update(['status' => 'archived']);
6767
$plan->delete();
68+
6869
return back()->with('success', 'Plan has been archived and hidden from new signups. Existing tenants are unaffected.');
6970
}
7071

7172
$plan->forceDelete();
72-
return back()->with('success', 'Pricing plan deleted permanently.');
73+
74+
return back()->with('success', 'Pricing plan deleted permanently.');
7375
}
7476

7577
protected function validatePlan(Request $request, $ignoreId = null): array
7678
{
7779
return $request->validate([
7880
'name' => ['required', 'string', 'max:255'],
79-
'slug' => ['nullable', 'string', 'max:255', 'unique:plans,slug,' . $ignoreId],
81+
'slug' => ['nullable', 'string', 'max:255', 'unique:plans,slug,'.$ignoreId],
8082
'tagline' => ['nullable', 'string', 'max:500'],
8183
'is_free' => ['boolean'],
8284
'is_contact_sales' => ['boolean'],
@@ -90,7 +92,7 @@ protected function validatePlan(Request $request, $ignoreId = null): array
9092
'auto_approve' => ['boolean'],
9193
'features' => ['nullable', 'array'],
9294
'badge_label' => ['nullable', 'string', 'max:50'],
93-
'status' => ['required', 'string', 'in:draft,active,archived'],
95+
'status' => ['required', 'string', 'in:draft,active,archived'],
9496
'sort_order' => ['nullable', 'integer'],
9597
]);
9698
}

app/Http/Controllers/Central/Admin/TenantManagementController.php

Lines changed: 20 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,27 @@
33
namespace App\Http\Controllers\Central\Admin;
44

55
use App\Http\Controllers\Controller;
6+
use App\Models\JobOrder;
7+
use App\Models\PayrollPeriod;
8+
use App\Models\Task;
69
use App\Models\Tenant;
7-
use Illuminate\Http\Request;
8-
use Illuminate\View\View;
9-
10-
use Illuminate\Support\Facades\Notification;
11-
use App\Notifications\Central\TenantApprovedNotification;
12-
10+
use App\Models\User;
1311
use App\Services\Central\PlatformActivityService;
1412
use App\Services\Central\TenantOnboardingService;
15-
use Throwable;
13+
use Illuminate\Http\Request;
14+
use Illuminate\View\View;
1615

1716
class TenantManagementController extends Controller
1817
{
1918
public function __construct(
2019
protected PlatformActivityService $activityService,
2120
protected TenantOnboardingService $onboardingService,
22-
)
23-
{}
21+
) {}
2422

2523
public function index(): View
2624
{
2725
$tenants = Tenant::with(['tenantPlan', 'plan'])->latest()->paginate(15);
26+
2827
return view('admin.tenants.index', compact('tenants'));
2928
}
3029

@@ -38,60 +37,19 @@ public function show(Tenant $tenant): View
3837
'total_payroll_value' => 0,
3938
];
4039

41-
// Pending/suspended workspaces may not have a provisioned tenant DB yet.
42-
if ($tenant->status === 'active') {
43-
$metrics = $tenant->run(function () {
44-
return [
45-
'workers_count' => \App\Models\User::workers()->count(),
46-
'jobs_count' => \App\Models\JobOrder::count(),
47-
'tasks_count' => \App\Models\Task::count(),
48-
'payroll_periods_count' => \App\Models\PayrollPeriod::count(),
49-
'total_payroll_value' => \App\Models\PayrollPeriod::sum('total_amount'),
50-
];
51-
});
52-
}
40+
$metrics = $tenant->run(function () {
41+
return [
42+
'workers_count' => User::workers()->count(),
43+
'jobs_count' => JobOrder::count(),
44+
'tasks_count' => Task::count(),
45+
'payroll_periods_count' => PayrollPeriod::count(),
46+
'total_payroll_value' => PayrollPeriod::sum('total_amount'),
47+
];
48+
});
5349

5450
return view('admin.tenants.show', compact('tenant', 'metrics'));
5551
}
5652

57-
public function approve(Tenant $tenant)
58-
{
59-
try {
60-
$this->onboardingService->approveTenant($tenant);
61-
} catch (Throwable $e) {
62-
return back()->withErrors([
63-
'approval' => "Failed to approve tenant '{$tenant->company_name}'. Please try again. Error: {$e->getMessage()}",
64-
]);
65-
}
66-
67-
// Log the action
68-
$this->activityService->log(
69-
'tenant.approved',
70-
"Approved workspace for '{$tenant->company_name}'",
71-
$tenant->id
72-
);
73-
74-
// Send Notification to the stored admin email
75-
Notification::route('mail', $tenant->admin_email)
76-
->notify(new TenantApprovedNotification($tenant));
77-
78-
return back()->with('success', "Tenant '{$tenant->company_name}' has been approved and notified.");
79-
}
80-
81-
public function suspend(Tenant $tenant)
82-
{
83-
$tenant->update(['status' => 'suspended']);
84-
85-
// Log the action
86-
$this->activityService->log(
87-
'tenant.suspended',
88-
"Suspended workspace for '{$tenant->company_name}'",
89-
$tenant->id
90-
);
91-
92-
return back()->with('success', "Tenant '{$tenant->company_name}' has been suspended.");
93-
}
94-
9553
public function update(Request $request, Tenant $tenant)
9654
{
9755
$validated = $request->validate([
@@ -115,7 +73,7 @@ public function impersonate(Tenant $tenant)
11573
{
11674
// Get the first admin user from the tenant database
11775
$adminUser = $tenant->run(function () {
118-
return \App\Models\User::where('role', 'admin')->first();
76+
return User::where('role', 'admin')->first();
11977
});
12078

12179
if (! $adminUser) {
@@ -137,8 +95,8 @@ public function impersonate(Tenant $tenant)
13795
$baseDomain = preg_replace('/:\\d+$/', '', (string) (config('tenancy.central_domains')[0] ?? 'localhost'));
13896
$subdomain = $tenant->subdomain ?: $tenant->getTenantKey();
13997
$port = request()->getPort();
140-
$portSegment = in_array((int) $port, [80, 443], true) ? '' : ':' . $port;
98+
$portSegment = in_array((int) $port, [80, 443], true) ? '' : ':'.$port;
14199

142-
return redirect()->away($scheme . $subdomain . '.' . $baseDomain . $portSegment . '/impersonate/' . $token->token);
100+
return redirect()->away($scheme.$subdomain.'.'.$baseDomain.$portSegment.'/impersonate/'.$token->token);
143101
}
144102
}

0 commit comments

Comments
 (0)