diff --git a/src/app/Http/Controllers/API/V4/Admin/StatsController.php b/src/app/Http/Controllers/API/V4/Admin/StatsController.php --- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php @@ -6,6 +6,7 @@ use App\User; use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; class StatsController extends \App\Http\Controllers\Controller @@ -18,6 +19,14 @@ public const COLOR_BLUE_DARK = '#0056b3'; public const COLOR_ORANGE = '#f1a539'; + /** @var array List of enabled charts */ + protected $charts = [ + 'discounts', + 'income', + 'users', + 'users-all', + ]; + /** * Fetch chart data * @@ -33,7 +42,7 @@ $method = 'chart' . implode('', array_map('ucfirst', explode('-', $chart))); - if (!method_exists($this, $method)) { + if (!in_array($chart, $this->charts) || !method_exists($this, $method)) { return $this->errorResponse(404); } @@ -53,9 +62,14 @@ ->join('users', 'users.id', '=', 'wallets.user_id') ->where('discount', '>', 0) ->whereNull('users.deleted_at') - ->groupBy('discounts.discount') - ->pluck('cnt', 'discount') - ->all(); + ->groupBy('discounts.discount'); + + $addTenantScope = function ($builder, $tenantId) { + return $builder->where('users.tenant_id', $tenantId); + }; + + $discounts = $this->applyTenantScope($discounts, $addTenantScope) + ->pluck('cnt', 'discount')->all(); $labels = array_keys($discounts); $discounts = array_values($discounts); @@ -119,7 +133,20 @@ ->where('updated_at', '>=', $start->toDateString()) ->where('status', PaymentProvider::STATUS_PAID) ->whereIn('type', [PaymentProvider::TYPE_ONEOFF, PaymentProvider::TYPE_RECURRING]) - ->groupByRaw('1') + ->groupByRaw('1'); + + $addTenantScope = function ($builder, $tenantId) { + $where = '`wallet_id` IN (' + . 'select `id` from `wallets` ' + . 'join `users` on (`wallets`.`user_id` = `users`.`id`) ' + . 'where `payments`.`wallet_id` = `wallets`.`id` ' + . 'and `users`.`tenant_id` = ' . intval($tenantId) + . ')'; + + return $builder->whereRaw($where); + }; + + $payments = $this->applyTenantScope($payments, $addTenantScope) ->pluck('amount', 'period') ->map(function ($amount) { return $amount / 100; @@ -185,14 +212,15 @@ $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); + + $created = $this->applyTenantScope($created)->get(); + $deleted = $this->applyTenantScope($deleted)->get(); $empty = array_fill_keys($labels, 0); $created = array_values(array_merge($empty, $created->pluck('cnt', 'period')->all())); @@ -260,16 +288,16 @@ $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) - ->groupByRaw('1') - ->get(); + ->groupByRaw('1'); - $count = DB::table('users')->whereNull('deleted_at')->count(); + $created = $this->applyTenantScope($created)->get(); + $deleted = $this->applyTenantScope($deleted)->get(); + $count = $this->applyTenantScope(DB::table('users')->whereNull('deleted_at'))->count(); $empty = array_fill_keys($labels, 0); $created = array_merge($empty, $created->pluck('cnt', 'period')->all()); @@ -313,4 +341,29 @@ ] ]; } + + /** + * Add tenant scope to the queries when needed + * + * @param \Illuminate\Database\Query\Builder $query The query + * @param callable $addQuery Additional tenant-scope query-modifier + * + * @return \Illuminate\Database\Query\Builder + */ + protected function applyTenantScope($query, $addQuery = null) + { + $user = Auth::guard()->user(); + + if ($user->role == 'reseller') { + if ($addQuery) { + $query = $addQuery($query, \config('app.tenant_id')); + } else { + $query = $query->withEnvTenant(); + } + } + + // TODO: Tenant selector for admins + + return $query; + } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/StatsController.php b/src/app/Http/Controllers/API/V4/Reseller/StatsController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/StatsController.php @@ -0,0 +1,14 @@ + this.loadChart(chart)) + this.chartTypes.forEach(chart => this.loadChart(chart)) }, methods: { drawChart(name, data) { diff --git a/src/resources/vue/Reseller/Dashboard.vue b/src/resources/vue/Reseller/Dashboard.vue --- a/src/resources/vue/Reseller/Dashboard.vue +++ b/src/resources/vue/Reseller/Dashboard.vue @@ -2,12 +2,19 @@
+ + Stats +
diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -180,5 +180,7 @@ Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Reseller\DiscountsController::class); + + Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); diff --git a/src/tests/Browser/Reseller/StatsTest.php b/src/tests/Browser/Reseller/StatsTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Reseller/StatsTest.php @@ -0,0 +1,54 @@ +browse(function (Browser $browser) { + $browser->visit('/stats')->on(new Home()); + }); + } + + /** + * Test Stats page + */ + public function testStats(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->assertSeeIn('@links .link-stats', 'Stats') + ->click('@links .link-stats') + ->on(new Stats()) + ->assertElementsCount('@container > div', 3) + ->waitFor('@container #chart-users svg') + ->assertSeeIn('@container #chart-users svg .title', 'Users - last 8 weeks') + ->waitFor('@container #chart-users-all svg') + ->assertSeeIn('@container #chart-users-all svg .title', 'All Users - last year') + ->waitFor('@container #chart-discounts svg') + ->assertSeeIn('@container #chart-discounts svg .title', 'Discounts'); + }); + } +} diff --git a/src/tests/Feature/Controller/Reseller/StatsTest.php b/src/tests/Feature/Controller/Reseller/StatsTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/StatsTest.php @@ -0,0 +1,89 @@ +) + */ + public function testChart(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller = $this->getTestUser('reseller@kolabnow.com'); + + // Unauth access + $response = $this->get("api/v4/stats/chart/discounts"); + $response->assertStatus(401); + + // Normal user + $response = $this->actingAs($user)->get("api/v4/stats/chart/discounts"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->get("api/v4/stats/chart/discounts"); + $response->assertStatus(403); + + // Unknown chart name + $response = $this->actingAs($reseller)->get("api/v4/stats/chart/unknown"); + $response->assertStatus(404); + + // 'income' chart + $response = $this->actingAs($reseller)->get("api/v4/stats/chart/income"); + $response->assertStatus(404); + + // 'discounts' chart + $response = $this->actingAs($reseller)->get("api/v4/stats/chart/discounts"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('Discounts', $json['title']); + $this->assertSame('donut', $json['type']); + $this->assertSame([], $json['data']['labels']); + $this->assertSame([['values' => []]], $json['data']['datasets']); + + // 'users' chart + $response = $this->actingAs($reseller)->get("api/v4/stats/chart/users"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('Users - last 8 weeks', $json['title']); + $this->assertCount(8, $json['data']['labels']); + $this->assertSame(date('Y-W'), $json['data']['labels'][7]); + $this->assertCount(2, $json['data']['datasets']); + $this->assertSame('Created', $json['data']['datasets'][0]['name']); + $this->assertSame('Deleted', $json['data']['datasets'][1]['name']); + + // 'users-all' chart + $response = $this->actingAs($reseller)->get("api/v4/stats/chart/users-all"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('All Users - last year', $json['title']); + $this->assertCount(54, $json['data']['labels']); + $this->assertCount(1, $json['data']['datasets']); + } +}