From 6e84cb2ceb881f4621d0ab93330c2de8f0512a70 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Wed, 25 Mar 2026 11:36:54 +0200 Subject: [PATCH] feat: nc admin page rebuild task --- .lintstagedrc.cjs | 2 +- lib/Controller/AdminController.php | 50 +++++ openapi-full.json | 101 ++++++++++ openapi.json | 101 ++++++++++ src/AdminSettings.vue | 178 +++++++++++------- tests/unit/Controller/AdminControllerTest.php | 5 + 6 files changed, 366 insertions(+), 71 deletions(-) diff --git a/.lintstagedrc.cjs b/.lintstagedrc.cjs index 14c1a66..75ec37a 100644 --- a/.lintstagedrc.cjs +++ b/.lintstagedrc.cjs @@ -13,5 +13,5 @@ module.exports = { } return commands }, - '*Controller.php': [() => 'make openapi', () => 'git add openapi-*.json'], + '*Controller.php': [() => 'make openapi', () => 'git add openapi.json openapi-*.json'], } diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 9ee412e..6905521 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -15,6 +15,7 @@ use OCA\Forum\Db\RoleMapper; use OCA\Forum\Db\ThreadMapper; use OCA\Forum\Migration\SeedHelper; use OCA\Forum\Service\AdminSettingsService; +use OCA\Forum\Service\StatsService; use OCA\Forum\Service\UserRoleService; use OCA\Forum\Service\UserService; use OCP\AppFramework\Http; @@ -43,6 +44,7 @@ class AdminController extends OCSController { private IUserManager $userManager, private IUserSession $userSession, private AdminSettingsService $settingsService, + private StatsService $statsService, private LoggerInterface $logger, ) { parent::__construct($appName, $request); @@ -296,6 +298,54 @@ class AdminController extends OCSController { } } + /** + * Rebuild all forum statistics (users, categories, threads) + * + * @return DataResponse + * + * 200: Stats rebuilt successfully + */ + #[NoAdminRequired] + #[RequirePermission('canAccessAdminTools')] + #[ApiRoute(verb: 'POST', url: '/api/admin/rebuild-stats')] + public function rebuildStats(): DataResponse { + try { + $userResult = $this->statsService->rebuildAllUserStats(); + $categoryResult = $this->statsService->rebuildAllCategoryStats(); + $threadResult = $this->statsService->rebuildAllThreadStats(); + + $messages = []; + $messages[] = sprintf( + 'Users processed: %d, created: %d, updated: %d', + $userResult['users'], + $userResult['created'], + $userResult['updated'] + ); + $messages[] = sprintf( + 'Categories processed: %d, updated: %d', + $categoryResult['categories'], + $categoryResult['updated'] + ); + $messages[] = sprintf( + 'Threads processed: %d, updated: %d', + $threadResult['threads'], + $threadResult['updated'] + ); + + $this->logger->info('Forum stats rebuild completed successfully'); + return new DataResponse([ + 'success' => true, + 'message' => implode("\n", $messages), + ]); + } catch (\Exception $e) { + $this->logger->error('Error rebuilding stats: ' . $e->getMessage()); + return new DataResponse([ + 'success' => false, + 'message' => 'Failed to rebuild stats: ' . $e->getMessage(), + ], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + /** * Get all available roles * diff --git a/openapi-full.json b/openapi-full.json index 6c14983..5a597f7 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -466,6 +466,107 @@ } } }, + "/ocs/v2.php/apps/forum/api/admin/rebuild-stats": { + "post": { + "operationId": "admin-rebuild-stats", + "summary": "Rebuild all forum statistics (users, categories, threads)", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Stats rebuilt successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "success", + "message" + ], + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/forum/api/admin/users/{userId}/roles/{roleId}": { "delete": { "operationId": "admin-remove-role", diff --git a/openapi.json b/openapi.json index 1c57701..5266bd0 100644 --- a/openapi.json +++ b/openapi.json @@ -466,6 +466,107 @@ } } }, + "/ocs/v2.php/apps/forum/api/admin/rebuild-stats": { + "post": { + "operationId": "admin-rebuild-stats", + "summary": "Rebuild all forum statistics (users, categories, threads)", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Stats rebuilt successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "success", + "message" + ], + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/forum/api/admin/users/{userId}/roles/{roleId}": { "delete": { "operationId": "admin-remove-role", diff --git a/src/AdminSettings.vue b/src/AdminSettings.vue index 5b14868..3d56cf2 100644 --- a/src/AdminSettings.vue +++ b/src/AdminSettings.vue @@ -8,17 +8,39 @@
-
- +
+ {{ strings.runRepairSeeds }} - -
{{ repairSeedsResult }}
+ +
{{ repairSeeds.result }}
+
+
+
+ + + + + {{ strings.rebuildStatsHelp }} + + +
+
+ + + {{ strings.runRebuildStats }} + + + +
{{ rebuildStats.result }}
@@ -42,7 +64,7 @@ id="user-id" v-model="userId" :placeholder="strings.userIdPlaceholder" - :disabled="assignRoleLoading" + :disabled="assignRole.loading" />
@@ -52,7 +74,7 @@ v-model="selectedRole" :options="roleOptions" :placeholder="strings.rolePlaceholder" - :disabled="assignRoleLoading || rolesLoading" + :disabled="assignRole.loading || rolesLoading" :loading="rolesLoading" />
@@ -61,19 +83,19 @@
{{ strings.assignRole }}
- -

{{ assignRoleResult }}

+ +

{{ assignRole.result }}

@@ -89,11 +111,31 @@ import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcTextField from '@nextcloud/vue/components/NcTextField' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import WrenchIcon from '@icons/Wrench.vue' +import ChartBoxIcon from '@icons/ChartBox.vue' import PlusIcon from '@icons/Plus.vue' import { ocs } from '@/axios' import { t } from '@nextcloud/l10n' +function createTask() { + return { loading: false, result: null, success: false } +} + +async function runTask(task, fn, fallbackError) { + try { + task.loading = true + task.result = null + await fn(task) + } catch (e) { + console.error(fallbackError, e) + task.success = false + task.result = + e.response?.data?.message || e.response?.data?.error || e.message || t('forum', fallbackError) + } finally { + task.loading = false + } +} + export default { name: 'AdminSettings', components: { @@ -104,14 +146,14 @@ export default { NcTextField, NcLoadingIcon, WrenchIcon, + ChartBoxIcon, PlusIcon, }, data() { return { - // Repair seeds - repairSeedsLoading: false, - repairSeedsResult: null, - repairSeedsSuccess: false, + repairSeeds: createTask(), + rebuildStats: createTask(), + assignRole: createTask(), // User roles rolesLoading: true, @@ -119,9 +161,6 @@ export default { roles: [], userId: '', selectedRole: null, - assignRoleLoading: false, - assignRoleResult: null, - assignRoleSuccess: false, strings: { title: t('forum', 'Forum'), @@ -131,10 +170,16 @@ export default { 'Run the repair database initial data command to restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists.', ), runRepairSeeds: t('forum', 'Run Repair Database Initial Data'), + rebuildStatsHeader: t('forum', 'Rebuild Statistics'), + rebuildStatsHelp: t( + 'forum', + 'Recalculate all forum statistics including account post counts, thread counts, and category counters. Use this if statistics appear incorrect or out of sync.', + ), + runRebuildStats: t('forum', 'Rebuild Statistics'), userRolesHeader: t('forum', 'User Roles'), userRolesHelp: t( 'forum', - 'Assign forum roles to users. This allows you to grant administrative or moderator privileges to specific users.', + 'Assign forum roles to accounts. This allows you to grant administrative or moderator privileges to specific accounts.', ), userIdLabel: t('forum', 'User ID'), userIdPlaceholder: t('forum', 'Enter user ID'), @@ -172,56 +217,49 @@ export default { this.rolesLoading = false } }, - async runRepairSeeds() { - try { - this.repairSeedsLoading = true - this.repairSeedsResult = null - const resp = await ocs.post('/admin/repair-seeds') - this.repairSeedsSuccess = resp.data.success - this.repairSeedsResult = resp.data.message - if (resp.data.success) { - await this.fetchRoles() - } - } catch (e) { - console.error('Failed to run repair seeds', e) - this.repairSeedsSuccess = false - // Extract error message from various possible locations in the response - const errorMessage = - e.response?.data?.message || - e.response?.data?.error || - e.message || - t('forum', 'Failed to run repair database initial data') - this.repairSeedsResult = errorMessage - } finally { - this.repairSeedsLoading = false - } + runRepairSeeds() { + return runTask( + this.repairSeeds, + async (task) => { + const resp = await ocs.post('/admin/repair-seeds') + task.success = resp.data.success + task.result = resp.data.message + if (resp.data.success) { + await this.fetchRoles() + } + }, + 'Failed to run repair database initial data', + ) }, - async assignRole() { + runRebuildStats() { + return runTask( + this.rebuildStats, + async (task) => { + const resp = await ocs.post('/admin/rebuild-stats') + task.success = resp.data.success + task.result = resp.data.message + }, + 'Failed to rebuild statistics', + ) + }, + runAssignRole() { if (!this.canAssignRole) return - - try { - this.assignRoleLoading = true - this.assignRoleResult = null - const resp = await ocs.post( - `/admin/users/${encodeURIComponent(this.userId.trim())}/roles`, - { - roleId: this.selectedRole.id, - }, - ) - this.assignRoleSuccess = resp.data.success - this.assignRoleResult = resp.data.message - if (resp.data.success) { - // Clear form on success - this.userId = '' - this.selectedRole = null - } - } catch (e) { - console.error('Failed to assign role', e) - this.assignRoleSuccess = false - this.assignRoleResult = e.response?.data?.message || t('forum', 'Failed to assign role') - } finally { - this.assignRoleLoading = false - } + return runTask( + this.assignRole, + async (task) => { + const resp = await ocs.post( + `/admin/users/${encodeURIComponent(this.userId.trim())}/roles`, + { roleId: this.selectedRole.id }, + ) + task.success = resp.data.success + task.result = resp.data.message + if (resp.data.success) { + this.userId = '' + this.selectedRole = null + } + }, + 'Failed to assign role', + ) }, }, } @@ -241,14 +279,14 @@ export default { margin-top: 16px; } - .repair-seeds-container { + .task-container { display: flex; flex-direction: column; gap: 16px; max-width: 600px; } - .repair-seeds-output { + .task-output { white-space: pre-wrap; word-break: break-word; margin: 0; diff --git a/tests/unit/Controller/AdminControllerTest.php b/tests/unit/Controller/AdminControllerTest.php index 82456aa..e9c6e0b 100644 --- a/tests/unit/Controller/AdminControllerTest.php +++ b/tests/unit/Controller/AdminControllerTest.php @@ -12,6 +12,7 @@ use OCA\Forum\Db\PostMapper; use OCA\Forum\Db\RoleMapper; use OCA\Forum\Db\ThreadMapper; use OCA\Forum\Service\AdminSettingsService; +use OCA\Forum\Service\StatsService; use OCA\Forum\Service\UserRoleService; use OCA\Forum\Service\UserService; use OCP\IRequest; @@ -45,6 +46,8 @@ class AdminControllerTest extends TestCase { private IUserSession $userSession; /** @var AdminSettingsService&MockObject */ private AdminSettingsService $settingsService; + /** @var StatsService&MockObject */ + private StatsService $statsService; /** @var LoggerInterface&MockObject */ private LoggerInterface $logger; /** @var IRequest&MockObject */ @@ -62,6 +65,7 @@ class AdminControllerTest extends TestCase { $this->userManager = $this->createMock(IUserManager::class); $this->userSession = $this->createMock(IUserSession::class); $this->settingsService = $this->createMock(AdminSettingsService::class); + $this->statsService = $this->createMock(StatsService::class); $this->logger = $this->createMock(LoggerInterface::class); $this->controller = new AdminController( @@ -77,6 +81,7 @@ class AdminControllerTest extends TestCase { $this->userManager, $this->userSession, $this->settingsService, + $this->statsService, $this->logger ); }