refactor: user stats -> forum users

This commit is contained in:
2025-11-29 23:07:54 +02:00
parent 0f6988b71c
commit 3e1e0d2ada
25 changed files with 492 additions and 292 deletions

View File

@@ -40,8 +40,8 @@ class RebuildAllStats extends Command {
protected function execute(InputInterface $input, OutputInterface $output): int {
$output->writeln('<info>Starting full stats rebuild...</info>');
// Rebuild user stats
$output->writeln('Rebuilding user stats...');
// Rebuild forum users
$output->writeln('Rebuilding forum users...');
$userResult = $this->statsService->rebuildAllUserStats();
$output->writeln(sprintf(
' <comment>Users processed: %d, created: %d, updated: %d</comment>',

View File

@@ -31,8 +31,8 @@ class RebuildUserStats extends Command {
$result = $this->statsService->rebuildAllUserStats();
$output->writeln(sprintf('Processed %d users', $result['users']));
$output->writeln(sprintf('Created %d new user stats', $result['created']));
$output->writeln(sprintf('Updated %d existing user stats', $result['updated']));
$output->writeln(sprintf('Created %d new forum users', $result['created']));
$output->writeln(sprintf('Updated %d existing forum users', $result['updated']));
$output->writeln('<info>User statistics rebuilt successfully!</info>');
return 0;

View File

@@ -9,10 +9,10 @@ namespace OCA\Forum\Controller;
use OCA\Forum\Attribute\RequirePermission;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\UserRoleMapper;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\AdminSettingsService;
use OCA\Forum\Service\UserService;
use OCP\AppFramework\Http;
@@ -30,7 +30,7 @@ class AdminController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private UserStatsMapper $userStatsMapper,
private ForumUserMapper $forumUserMapper,
private UserService $userService,
private ThreadMapper $threadMapper,
private PostMapper $postMapper,
@@ -62,20 +62,20 @@ class AdminController extends OCSController {
}
// Get total counts
$totalUsers = $this->userStatsMapper->countAll();
$totalUsers = $this->forumUserMapper->countAll();
$totalThreads = $this->threadMapper->countAll();
$totalPosts = $this->postMapper->countAll();
$totalCategories = $this->categoryMapper->countAll();
// Get recent activity (last 7 days)
$weekAgo = time() - (7 * 24 * 60 * 60);
$recentUsers = $this->userStatsMapper->countSince($weekAgo);
$recentUsers = $this->forumUserMapper->countSince($weekAgo);
$recentThreads = $this->threadMapper->countSince($weekAgo);
$recentPosts = $this->postMapper->countSince($weekAgo);
// Get top contributors (users with most posts)
$topContributorsAllTime = $this->userStatsMapper->getTopContributors(5);
$topContributorsRecent = $this->userStatsMapper->getTopContributorsSince($weekAgo, 5);
$topContributorsAllTime = $this->forumUserMapper->getTopContributors(5);
$topContributorsRecent = $this->forumUserMapper->getTopContributorsSince($weekAgo, 5);
return new DataResponse([
'totals' => [
@@ -110,11 +110,11 @@ class AdminController extends OCSController {
#[ApiRoute(verb: 'GET', url: '/api/admin/users')]
public function users(): DataResponse {
try {
// Get all user stats indexed by userId for quick lookup
$allStats = $this->userStatsMapper->findAll();
$statsByUserId = [];
foreach ($allStats as $stats) {
$statsByUserId[$stats->getUserId()] = $stats;
// Get all forum users indexed by userId for quick lookup
$allForumUsers = $this->forumUserMapper->findAll();
$forumUsersByUserId = [];
foreach ($allForumUsers as $forumUser) {
$forumUsersByUserId[$forumUser->getUserId()] = $forumUser;
}
// Collect all user IDs first
@@ -126,20 +126,20 @@ class AdminController extends OCSController {
// Enrich all users at once for performance (includes roles)
$enrichedUserData = $this->userService->enrichMultipleUsers($userIds);
// Build final user list with forum stats
// Build final user list with forum user data
$enrichedUsers = [];
foreach ($userIds as $userId) {
$userInfo = $enrichedUserData[$userId];
$stats = $statsByUserId[$userId] ?? null;
$forumUser = $forumUsersByUserId[$userId] ?? null;
$userData = [
'userId' => $userId,
'displayName' => $userInfo['displayName'],
'postCount' => $stats ? $stats->getPostCount() : 0,
'threadCount' => $stats ? $stats->getThreadCount() : 0,
'createdAt' => $stats ? $stats->getCreatedAt() : 0,
'updatedAt' => $stats ? $stats->getUpdatedAt() : 0,
'deletedAt' => $stats ? $stats->getDeletedAt() : null,
'postCount' => $forumUser ? $forumUser->getPostCount() : 0,
'threadCount' => $forumUser ? $forumUser->getThreadCount() : 0,
'createdAt' => $forumUser ? $forumUser->getCreatedAt() : 0,
'updatedAt' => $forumUser ? $forumUser->getUpdatedAt() : 0,
'deletedAt' => $forumUser ? $forumUser->getDeletedAt() : null,
'isDeleted' => $userInfo['isDeleted'],
'roles' => $userInfo['roles'],
];

View File

@@ -7,7 +7,7 @@ declare(strict_types=1);
namespace OCA\Forum\Controller;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Db\ForumUserMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -20,14 +20,14 @@ use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* Controller for user statistics
* Note: User stats are automatically created on first post/thread
* Controller for forum users
* Note: Forum users are automatically created on first post/thread
*/
class ForumUserController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private UserStatsMapper $userStatsMapper,
private ForumUserMapper $forumUserMapper,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -35,34 +35,34 @@ class ForumUserController extends OCSController {
}
/**
* Get all user statistics
* Get all forum users
*
* @return DataResponse<Http::STATUS_OK, list<array<string, mixed>>, array{}>
*
* 200: User statistics returned
* 200: Forum users returned
*/
#[NoAdminRequired]
#[PublicPage]
#[ApiRoute(verb: 'GET', url: '/api/users')]
public function index(): DataResponse {
try {
$users = $this->userStatsMapper->findAll();
$users = $this->forumUserMapper->findAll();
return new DataResponse(array_map(fn ($u) => $u->jsonSerialize(), $users));
} catch (\Exception $e) {
$this->logger->error('Error fetching user stats: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch user stats'], Http::STATUS_INTERNAL_SERVER_ERROR);
$this->logger->error('Error fetching forum users: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch forum users'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get user statistics by Nextcloud user ID
* Special case: use "me" to get current user stats
* Get forum user by Nextcloud user ID
* Special case: use "me" to get current forum user
*
* @param string $userId Nextcloud user ID or "me" for current user
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: string}, array{}>
*
* 200: User stats returned
* 404: User has no stats (hasn't posted yet) or guest user accessing "me"
* 200: Forum user returned
* 404: Forum user not found (user has not posted yet) or guest user accessing "me"
*/
#[NoAdminRequired]
#[PublicPage]
@@ -73,40 +73,40 @@ class ForumUserController extends OCSController {
if ($userId === 'me') {
$currentUser = $this->userSession->getUser();
if (!$currentUser) {
// Guest users have no stats - return 404 like a user who hasn't posted yet
return new DataResponse(['error' => 'User stats not found'], Http::STATUS_NOT_FOUND);
// Guest users have no forum profile - return 404 like a user who hasn't posted yet
return new DataResponse(['error' => 'Forum user not found'], Http::STATUS_NOT_FOUND);
}
$userId = $currentUser->getUID();
}
$stats = $this->userStatsMapper->find($userId);
return new DataResponse($stats->jsonSerialize());
$forumUser = $this->forumUserMapper->find($userId);
return new DataResponse($forumUser->jsonSerialize());
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'User stats not found'], Http::STATUS_NOT_FOUND);
return new DataResponse(['error' => 'Forum user not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error fetching user stats: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch user stats'], Http::STATUS_INTERNAL_SERVER_ERROR);
$this->logger->error('Error fetching forum user: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch forum user'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Create user stats
* Note: This is typically not needed as stats are auto-created on first post
* Create forum user
* Note: This is typically not needed as forum users are auto-created on first post
*
* @param string $userId Nextcloud user ID
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>
*
* 201: User stats created
* 201: Forum user created
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/users')]
public function create(string $userId): DataResponse {
try {
$stats = $this->userStatsMapper->createOrUpdate($userId);
return new DataResponse($stats->jsonSerialize(), Http::STATUS_CREATED);
$forumUser = $this->forumUserMapper->createOrUpdate($userId);
return new DataResponse($forumUser->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error creating user stats: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to create user stats'], Http::STATUS_INTERNAL_SERVER_ERROR);
$this->logger->error('Error creating forum user: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to create forum user'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -10,12 +10,12 @@ namespace OCA\Forum\Controller;
use OCA\Forum\Attribute\RequirePermission;
use OCA\Forum\Db\BBCodeMapper;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Db\Post;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\ReactionMapper;
use OCA\Forum\Db\ReadMarkerMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\BBCodeService;
use OCA\Forum\Service\NotificationService;
use OCA\Forum\Service\PermissionService;
@@ -39,7 +39,7 @@ class PostController extends OCSController {
private PostMapper $postMapper,
private ThreadMapper $threadMapper,
private CategoryMapper $categoryMapper,
private UserStatsMapper $userStatsMapper,
private ForumUserMapper $forumUserMapper,
private ReactionMapper $reactionMapper,
private BBCodeService $bbCodeService,
private BBCodeMapper $bbCodeMapper,
@@ -364,12 +364,12 @@ class PostController extends OCSController {
// Don't fail the request if thread update fails
}
// Update user stats post count (auto-creates stats if needed)
// Update forum user post count (auto-creates forum user if needed)
try {
$this->userStatsMapper->incrementPostCount($user->getUID());
$this->forumUserMapper->incrementPostCount($user->getUID());
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats post count: ' . $e->getMessage());
// Don't fail the request if stats update fails
$this->logger->warning('Failed to update forum user post count: ' . $e->getMessage());
// Don't fail the request if forum user update fails
}
// Update the category's post count
@@ -506,18 +506,18 @@ class PostController extends OCSController {
// Don't fail the request if thread update fails
}
// Update user stats - decrement post count, and thread count if it's the first post
// Update forum user - decrement post count, and thread count if it's the first post
try {
if ($post->getIsFirstPost()) {
// First post: decrement thread count only
$this->userStatsMapper->decrementThreadCount($post->getAuthorId());
$this->forumUserMapper->decrementThreadCount($post->getAuthorId());
} else {
// Reply post: decrement post count only
$this->userStatsMapper->decrementPostCount($post->getAuthorId());
$this->forumUserMapper->decrementPostCount($post->getAuthorId());
}
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats after post deletion: ' . $e->getMessage());
// Don't fail the request if stats update fails
$this->logger->warning('Failed to update forum user after post deletion: ' . $e->getMessage());
// Don't fail the request if forum user update fails
}
// Update category post count (only for reply posts, not first posts)

View File

@@ -9,12 +9,12 @@ namespace OCA\Forum\Controller;
use OCA\Forum\Attribute\RequirePermission;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Db\Post;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\Thread;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\PermissionService;
use OCA\Forum\Service\ThreadEnrichmentService;
use OCA\Forum\Service\UserPreferencesService;
@@ -37,7 +37,7 @@ class ThreadController extends OCSController {
private ThreadMapper $threadMapper,
private CategoryMapper $categoryMapper,
private PostMapper $postMapper,
private UserStatsMapper $userStatsMapper,
private ForumUserMapper $forumUserMapper,
private ThreadSubscriptionMapper $threadSubscriptionMapper,
private ThreadEnrichmentService $threadEnrichmentService,
private UserPreferencesService $userPreferencesService,
@@ -331,11 +331,11 @@ class ThreadController extends OCSController {
$this->logger->warning('Failed to update category counts: ' . $e->getMessage());
}
// Update user stats (thread count only, first post doesn't count)
// Update forum user (thread count only, first post doesn't count)
try {
$this->userStatsMapper->incrementThreadCount($user->getUID());
$this->forumUserMapper->incrementThreadCount($user->getUID());
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats: ' . $e->getMessage());
$this->logger->warning('Failed to update forum user: ' . $e->getMessage());
}
// Auto-subscribe the thread creator to receive notifications (if preference is enabled)
@@ -603,16 +603,16 @@ class ThreadController extends OCSController {
// Don't fail the request if category update fails
}
// Update author's user stats (decrement thread count and all posts in this thread)
// Update author's forum user (decrement thread count and all posts in this thread)
try {
$this->userStatsMapper->decrementThreadCount($thread->getAuthorId());
$this->forumUserMapper->decrementThreadCount($thread->getAuthorId());
// Decrement post count by the number of posts in this thread
if ($thread->getPostCount() > 0) {
$this->userStatsMapper->decrementPostCount($thread->getAuthorId(), $thread->getPostCount());
$this->forumUserMapper->decrementPostCount($thread->getAuthorId(), $thread->getPostCount());
}
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats after thread deletion: ' . $e->getMessage());
// Don't fail the request if stats update fails
$this->logger->warning('Failed to update forum user after thread deletion: ' . $e->getMessage());
// Don't fail the request if forum user update fails
}
return new DataResponse([

View File

@@ -27,9 +27,9 @@ class RebuildStatsTask extends TimedJob {
protected function run($arguments): void {
$this->logger->info('Starting weekly stats rebuild');
// Rebuild user stats
// Rebuild forum users
$userResult = $this->statsService->rebuildAllUserStats();
$this->logger->info('User stats rebuild completed', [
$this->logger->info('Forum users rebuild completed', [
'users' => $userResult['users'],
'created' => $userResult['created'],
'updated' => $userResult['updated'],

View File

@@ -30,7 +30,7 @@ use OCP\AppFramework\Db\Entity;
* @method int getUpdatedAt()
* @method void setUpdatedAt(int $updatedAt)
*/
class UserStats extends Entity implements JsonSerializable {
class ForumUser extends Entity implements JsonSerializable {
public $id;
protected string $userId = '';
protected int $postCount = 0;

View File

@@ -14,22 +14,22 @@ use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<UserStats>
* @template-extends QBMapper<ForumUser>
*/
class UserStatsMapper extends QBMapper {
class ForumUserMapper extends QBMapper {
public function __construct(
IDBConnection $db,
) {
parent::__construct($db, Application::tableName('forum_user_stats'), UserStats::class);
parent::__construct($db, Application::tableName('forum_users'), ForumUser::class);
}
/**
* Find user stats by user ID
* Find forum user by user ID
*
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function find(string $userId): UserStats {
public function find(string $userId): ForumUser {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
@@ -40,9 +40,9 @@ class UserStatsMapper extends QBMapper {
}
/**
* Find all user stats
* Find all forum users
*
* @return array<UserStats>
* @return array<ForumUser>
*/
public function findAll(): array {
$qb = $this->db->getQueryBuilder();
@@ -53,10 +53,10 @@ class UserStatsMapper extends QBMapper {
}
/**
* Find user stats by multiple user IDs
* Find forum users by multiple user IDs
*
* @param array<string> $userIds
* @return array<UserStats>
* @return array<ForumUser>
*/
public function findByUserIds(array $userIds): array {
if (empty($userIds)) {
@@ -71,45 +71,45 @@ class UserStatsMapper extends QBMapper {
}
/**
* Create or update user stats (upsert pattern)
* This is used when we need to ensure stats exist for a user
* Create or update forum user (upsert pattern)
* This is used when we need to ensure a forum user record exists
*/
public function createOrUpdate(string $userId): UserStats {
public function createOrUpdate(string $userId): ForumUser {
try {
return $this->find($userId);
} catch (DoesNotExistException $e) {
$stats = new UserStats();
$stats->setUserId($userId);
$stats->setPostCount(0);
$stats->setThreadCount(0);
$stats->setCreatedAt(time());
$stats->setUpdatedAt(time());
/** @var UserStats */
return $this->insert($stats);
$user = new ForumUser();
$user->setUserId($userId);
$user->setPostCount(0);
$user->setThreadCount(0);
$user->setCreatedAt(time());
$user->setUpdatedAt(time());
/** @var ForumUser */
return $this->insert($user);
}
}
/**
* Increment post count for a user
* Auto-creates stats if user doesn't exist
* Auto-creates record if user doesn't exist
*/
public function incrementPostCount(string $userId, int $amount = 1): void {
$stats = $this->createOrUpdate($userId);
$stats->setPostCount($stats->getPostCount() + $amount);
$stats->setLastPostAt(time());
$stats->setUpdatedAt(time());
$this->update($stats);
$user = $this->createOrUpdate($userId);
$user->setPostCount($user->getPostCount() + $amount);
$user->setLastPostAt(time());
$user->setUpdatedAt(time());
$this->update($user);
}
/**
* Increment thread count for a user
* Auto-creates stats if user doesn't exist
* Auto-creates record if user doesn't exist
*/
public function incrementThreadCount(string $userId, int $amount = 1): void {
$stats = $this->createOrUpdate($userId);
$stats->setThreadCount($stats->getThreadCount() + $amount);
$stats->setUpdatedAt(time());
$this->update($stats);
$user = $this->createOrUpdate($userId);
$user->setThreadCount($user->getThreadCount() + $amount);
$user->setUpdatedAt(time());
$this->update($user);
}
/**
@@ -117,12 +117,12 @@ class UserStatsMapper extends QBMapper {
*/
public function decrementPostCount(string $userId, int $amount = 1): void {
try {
$stats = $this->find($userId);
$stats->setPostCount(max(0, $stats->getPostCount() - $amount));
$stats->setUpdatedAt(time());
$this->update($stats);
$user = $this->find($userId);
$user->setPostCount(max(0, $user->getPostCount() - $amount));
$user->setUpdatedAt(time());
$this->update($user);
} catch (DoesNotExistException $e) {
// User stats don't exist, nothing to decrement
// User record doesn't exist, nothing to decrement
}
}
@@ -131,12 +131,12 @@ class UserStatsMapper extends QBMapper {
*/
public function decrementThreadCount(string $userId, int $amount = 1): void {
try {
$stats = $this->find($userId);
$stats->setThreadCount(max(0, $stats->getThreadCount() - $amount));
$stats->setUpdatedAt(time());
$this->update($stats);
$user = $this->find($userId);
$user->setThreadCount(max(0, $user->getThreadCount() - $amount));
$user->setUpdatedAt(time());
$this->update($user);
} catch (DoesNotExistException $e) {
// User stats don't exist, nothing to decrement
// User record doesn't exist, nothing to decrement
}
}
@@ -291,20 +291,20 @@ class UserStatsMapper extends QBMapper {
*/
public function markDeleted(string $userId): void {
try {
$stats = $this->find($userId);
$stats->setDeletedAt(time());
$stats->setUpdatedAt(time());
$this->update($stats);
$user = $this->find($userId);
$user->setDeletedAt(time());
$user->setUpdatedAt(time());
$this->update($user);
} catch (DoesNotExistException $e) {
// User has no stats, create a record marking them as deleted
$stats = new UserStats();
$stats->setUserId($userId);
$stats->setPostCount(0);
$stats->setThreadCount(0);
$stats->setDeletedAt(time());
$stats->setCreatedAt(time());
$stats->setUpdatedAt(time());
$this->insert($stats);
// User has no record, create one marking them as deleted
$user = new ForumUser();
$user->setUserId($userId);
$user->setPostCount(0);
$user->setThreadCount(0);
$user->setDeletedAt(time());
$user->setCreatedAt(time());
$user->setUpdatedAt(time());
$this->insert($user);
}
}
}

View File

@@ -7,8 +7,8 @@ declare(strict_types=1);
namespace OCA\Forum\Listener;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\StatsService;
use OCA\Forum\Service\UserRoleService;
use OCP\AppFramework\Db\DoesNotExistException;
@@ -23,7 +23,7 @@ use Psr\Log\LoggerInterface;
*/
class UserEventListener implements IEventListener {
public function __construct(
private UserStatsMapper $userStatsMapper,
private ForumUserMapper $forumUserMapper,
private StatsService $statsService,
private UserRoleService $userRoleService,
private RoleMapper $roleMapper,
@@ -44,11 +44,11 @@ class UserEventListener implements IEventListener {
$userId = $user->getUID();
try {
// Create user stats with zero counts for new user
// Create forum user record with zero counts for new user
$this->statsService->rebuildUserStats($userId);
$this->logger->info("Created user stats for new Nextcloud user: {$userId}");
$this->logger->info("Created forum user record for new Nextcloud user: {$userId}");
} catch (\Exception $ex) {
$this->logger->error("Failed to create user stats for new user: {$userId}", [
$this->logger->error("Failed to create forum user record for new user: {$userId}", [
'exception' => $ex->getMessage(),
]);
}
@@ -72,13 +72,13 @@ class UserEventListener implements IEventListener {
$userId = $user->getUID();
try {
// Soft delete: mark stats as deleted if they exist
// Stats only exist if user has posted, so this may not find anything
$this->userStatsMapper->markDeleted($userId);
$this->logger->info("Soft-deleted user stats for Nextcloud user: {$userId}");
// Soft delete: mark forum user as deleted if record exists
// Record only exists if user has posted, so this may not find anything
$this->forumUserMapper->markDeleted($userId);
$this->logger->info("Soft-deleted forum user record for Nextcloud user: {$userId}");
} catch (\Exception $ex) {
// If stats don't exist, that's fine - user never posted
$this->logger->debug("No user stats found to delete for Nextcloud user: {$userId}");
// If record doesn't exist, that's fine - user never posted
$this->logger->debug("No forum user record found to delete for Nextcloud user: {$userId}");
}
}
}

View File

@@ -1025,10 +1025,10 @@ class SeedHelper {
}
}
// Create user stats for the admin user if it does not exist
// Create forum user for the admin user if it does not exist
$qb = $db->getQueryBuilder();
$qb->select('user_id')
->from('forum_user_stats')
->from('forum_users')
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($adminUserId)));
$result = $qb->executeQuery();
$statsExists = $result->fetch();
@@ -1036,7 +1036,7 @@ class SeedHelper {
if (!$statsExists) {
$qb = $db->getQueryBuilder();
$qb->insert('forum_user_stats')
$qb->insert('forum_users')
->values([
'user_id' => $qb->createNamedParameter($adminUserId),
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
@@ -1049,7 +1049,7 @@ class SeedHelper {
} else {
// Update existing stats to increment thread count
$qb = $db->getQueryBuilder();
$qb->update('forum_user_stats')
$qb->update('forum_users')
->set('thread_count', $qb->func()->add('thread_count', $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->set('last_post_at', $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->set('updated_at', $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))

View File

@@ -8,14 +8,17 @@ declare(strict_types=1);
namespace OCA\Forum\Migration;
use Closure;
use OCA\Forum\Service\StatsService;
use OCP\DB\ISchemaWrapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\IUserManager;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version2Date20251114222614 extends SimpleMigrationStep {
public function __construct(
private StatsService $statsService,
private IDBConnection $db,
private IUserManager $userManager,
) {
}
@@ -108,30 +111,162 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$output->info('Creating user statistics for all users...');
$result = $this->statsService->rebuildAllUserStats();
$result = $this->rebuildAllUserStatsLegacy();
$output->info(sprintf('Processed %d users', $result['users']));
$output->info(sprintf('Created %d new user stats', $result['created']));
$output->info(sprintf('Updated %d existing user stats', $result['updated']));
$output->info(sprintf('Created %d new forum users', $result['created']));
$output->info(sprintf('Updated %d existing forum users', $result['updated']));
$output->info('User statistics created successfully!');
// Subscribe thread authors to their threads
$this->subscribeAuthorsToThreads($output);
}
/**
* Rebuild user stats using the old table name (forum_user_stats)
* This is needed because Version8 hasn't renamed the table yet
*/
private function rebuildAllUserStatsLegacy(): array {
// Get all user IDs from Nextcloud
$users = [];
$this->userManager->callForAllUsers(function ($user) use (&$users) {
$users[] = $user->getUID();
});
$updated = 0;
$created = 0;
foreach ($users as $userId) {
$wasCreated = $this->rebuildUserStatsLegacy($userId);
if ($wasCreated) {
$created++;
} else {
$updated++;
}
}
return [
'users' => count($users),
'updated' => $updated,
'created' => $created,
];
}
/**
* Rebuild stats for a single user using the old table name
*/
private function rebuildUserStatsLegacy(string $userId): bool {
// Count non-deleted threads created by this user
$threadQb = $this->db->getQueryBuilder();
$threadQb->select($threadQb->func()->count('*', 'count'))
->from('forum_threads')
->where($threadQb->expr()->eq('author_id', $threadQb->createNamedParameter($userId)))
->andWhere($threadQb->expr()->isNull('deleted_at'));
$threadResult = $threadQb->executeQuery();
$threadCount = (int)($threadResult->fetchOne() ?? 0);
$threadResult->closeCursor();
// Count non-deleted posts created by this user (from non-deleted threads)
// Exclude is_first_post posts as they are counted as threads
$postQb = $this->db->getQueryBuilder();
$postQb->select($postQb->func()->count('*', 'count'))
->from('forum_posts', 'p')
->innerJoin('p', 'forum_threads', 't', $postQb->expr()->eq('p.thread_id', 't.id'))
->where($postQb->expr()->eq('p.author_id', $postQb->createNamedParameter($userId)))
->andWhere($postQb->expr()->isNull('p.deleted_at'))
->andWhere($postQb->expr()->isNull('t.deleted_at'))
->andWhere($postQb->expr()->eq('p.is_first_post', $postQb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)));
$postResult = $postQb->executeQuery();
$postCount = (int)($postResult->fetchOne() ?? 0);
$postResult->closeCursor();
// Get the timestamp of the last non-deleted post (from non-deleted threads)
$lastPostQb = $this->db->getQueryBuilder();
$lastPostQb->select('p.created_at')
->from('forum_posts', 'p')
->innerJoin('p', 'forum_threads', 't', $lastPostQb->expr()->eq('p.thread_id', 't.id'))
->where($lastPostQb->expr()->eq('p.author_id', $lastPostQb->createNamedParameter($userId)))
->andWhere($lastPostQb->expr()->isNull('p.deleted_at'))
->andWhere($lastPostQb->expr()->isNull('t.deleted_at'))
->orderBy('p.created_at', 'DESC')
->setMaxResults(1);
$lastPostResult = $lastPostQb->executeQuery();
$lastPostAt = $lastPostResult->fetchOne();
$lastPostResult->closeCursor();
// Check if forum user record already exists (using OLD table name)
$checkQb = $this->db->getQueryBuilder();
$checkQb->select('user_id')
->from('forum_user_stats') // OLD table name!
->where($checkQb->expr()->eq('user_id', $checkQb->createNamedParameter($userId)));
$checkResult = $checkQb->executeQuery();
$exists = $checkResult->fetch();
$checkResult->closeCursor();
$timestamp = time();
if ($exists) {
// Update existing record (using OLD table name)
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('forum_user_stats') // OLD table name!
->set('thread_count', $updateQb->createNamedParameter($threadCount, IQueryBuilder::PARAM_INT))
->set('post_count', $updateQb->createNamedParameter($postCount, IQueryBuilder::PARAM_INT))
->set('updated_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT))
->where($updateQb->expr()->eq('user_id', $updateQb->createNamedParameter($userId)));
if ($lastPostAt) {
$updateQb->set('last_post_at', $updateQb->createNamedParameter((int)$lastPostAt, IQueryBuilder::PARAM_INT));
}
$updateQb->executeStatement();
return false;
} else {
// Create new record (using OLD table name)
$insertQb = $this->db->getQueryBuilder();
$insertQb->insert('forum_user_stats') // OLD table name!
->values([
'user_id' => $insertQb->createNamedParameter($userId),
'thread_count' => $insertQb->createNamedParameter($threadCount, IQueryBuilder::PARAM_INT),
'post_count' => $insertQb->createNamedParameter($postCount, IQueryBuilder::PARAM_INT),
'last_post_at' => $insertQb->createNamedParameter($lastPostAt ? (int)$lastPostAt : null, IQueryBuilder::PARAM_INT),
'created_at' => $insertQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT),
'updated_at' => $insertQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT),
]);
try {
$insertQb->executeStatement();
return true;
} catch (\Exception $e) {
// If insert fails (race condition), try updating instead
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('forum_user_stats') // OLD table name!
->set('thread_count', $updateQb->createNamedParameter($threadCount, IQueryBuilder::PARAM_INT))
->set('post_count', $updateQb->createNamedParameter($postCount, IQueryBuilder::PARAM_INT))
->set('updated_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT))
->where($updateQb->expr()->eq('user_id', $updateQb->createNamedParameter($userId)));
if ($lastPostAt) {
$updateQb->set('last_post_at', $updateQb->createNamedParameter((int)$lastPostAt, IQueryBuilder::PARAM_INT));
}
$updateQb->executeStatement();
return false;
}
}
}
/**
* Subscribe all thread authors to their threads
*/
private function subscribeAuthorsToThreads(IOutput $output): void {
$output->info('Subscribing thread authors to their threads...');
$db = \OC::$server->get(\OCP\IDBConnection::class);
$timestamp = time();
$subscribed = 0;
try {
// Get all threads with their authors
$qb = $db->getQueryBuilder();
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'author_id')
->from('forum_threads');
$result = $qb->executeQuery();
@@ -143,10 +278,10 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
$authorId = $thread['author_id'];
// Check if author is already subscribed
$qb = $db->getQueryBuilder();
$qb = $this->db->getQueryBuilder();
$qb->select('id')
->from('forum_thread_subs')
->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($authorId)));
$result = $qb->executeQuery();
$exists = $result->fetch();
@@ -154,12 +289,12 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
if (!$exists) {
// Subscribe the author to their thread
$qb = $db->getQueryBuilder();
$qb = $this->db->getQueryBuilder();
$qb->insert('forum_thread_subs')
->values([
'thread_id' => $qb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'thread_id' => $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT),
'user_id' => $qb->createNamedParameter($authorId),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT),
])
->executeStatement();
$subscribed++;

View File

@@ -78,7 +78,7 @@ class Version7Date20251124120000 extends SimpleMigrationStep {
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$this->migrateConfigValues($output);
$this->updateExistingRoleFlags($output);
SeedHelper::seedAll($output);
// Note: SeedHelper::seedAll() is called in Version9 after table rename
}
/**

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Version 9 Migration:
* - Rename forum_user_stats to forum_users
* - Run seed (creates initial data if not exists)
*/
class Version9Date20251129000000 extends SimpleMigrationStep {
public function __construct(
private IDBConnection $db,
private IConfig $config,
) {
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
// Rename forum_user_stats to forum_users using raw SQL
// ISchemaWrapper doesn't have renameTable method
$platform = $this->db->getDatabasePlatform();
$prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_');
$oldTable = $prefix . 'forum_user_stats';
$newTable = $prefix . 'forum_users';
// Check if old table exists and new table doesn't
$schema = $schemaClosure();
if ($schema->hasTable('forum_user_stats') && !$schema->hasTable('forum_users')) {
$output->info('Renaming forum_user_stats to forum_users...');
// Use platform-specific rename syntax
$platformName = $platform->getName();
if ($platformName === 'mysql' || $platformName === 'mariadb') {
$this->db->executeStatement("RENAME TABLE `{$oldTable}` TO `{$newTable}`");
} elseif ($platformName === 'postgresql') {
$this->db->executeStatement("ALTER TABLE \"{$oldTable}\" RENAME TO \"{$newTable}\"");
} else {
// SQLite and others
$this->db->executeStatement("ALTER TABLE \"{$oldTable}\" RENAME TO \"{$newTable}\"");
}
$output->info('Table renamed successfully');
}
// Run seed after table rename (SeedHelper uses forum_users table)
SeedHelper::seedAll($output);
}
}

View File

@@ -95,10 +95,10 @@ class StatsService {
$lastPostAt = $lastPostResult->fetchOne();
$lastPostResult->closeCursor();
// Check if user stats already exist
// Check if forum user record already exists
$checkQb = $this->db->getQueryBuilder();
$checkQb->select('id')
->from('forum_user_stats')
$checkQb->select('user_id')
->from('forum_users')
->where($checkQb->expr()->eq('user_id', $checkQb->createNamedParameter($userId)));
$checkResult = $checkQb->executeQuery();
$exists = $checkResult->fetch();
@@ -107,9 +107,9 @@ class StatsService {
$timestamp = time();
if ($exists) {
// Update existing stats
// Update existing record
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('forum_user_stats')
$updateQb->update('forum_users')
->set('thread_count', $updateQb->createNamedParameter($threadCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->set('post_count', $updateQb->createNamedParameter($postCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->set('updated_at', $updateQb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
@@ -122,9 +122,9 @@ class StatsService {
$updateQb->executeStatement();
return false;
} else {
// Create new stats
// Create new record
$insertQb = $this->db->getQueryBuilder();
$insertQb->insert('forum_user_stats')
$insertQb->insert('forum_users')
->values([
'user_id' => $insertQb->createNamedParameter($userId),
'thread_count' => $insertQb->createNamedParameter($threadCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
@@ -139,13 +139,13 @@ class StatsService {
return true;
} catch (\Exception $e) {
// If insert fails (race condition), try updating instead
$this->logger->warning('Failed to create user stats, attempting update', [
$this->logger->warning('Failed to create forum user record, attempting update', [
'userId' => $userId,
'exception' => $e->getMessage(),
]);
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('forum_user_stats')
$updateQb->update('forum_users')
->set('thread_count', $updateQb->createNamedParameter($threadCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->set('post_count', $updateQb->createNamedParameter($postCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->set('updated_at', $updateQb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))

View File

@@ -8,7 +8,7 @@ declare(strict_types=1);
namespace OCA\Forum\Service;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Db\ForumUserMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
@@ -20,7 +20,7 @@ class UserPreferencesService {
/** Preference key for upload directory path */
public const PREF_UPLOAD_DIRECTORY = 'upload_directory';
/** Preference key for user signature (stored in user_stats table) */
/** Preference key for user signature (stored in forum_users table) */
public const PREF_SIGNATURE = 'signature';
/** @var array<string, mixed> Default preference values */
@@ -37,14 +37,14 @@ class UserPreferencesService {
self::PREF_SIGNATURE,
];
/** @var array<string> Keys stored in user_stats table instead of config */
private const USER_STATS_KEYS = [
/** @var array<string> Keys stored in forum_users table instead of config */
private const FORUM_USER_KEYS = [
self::PREF_SIGNATURE,
];
public function __construct(
private IConfig $config,
private UserStatsMapper $userStatsMapper,
private ForumUserMapper $forumUserMapper,
private LoggerInterface $logger,
) {
}
@@ -78,9 +78,9 @@ class UserPreferencesService {
throw new \InvalidArgumentException("Invalid preference key: $key");
}
// Handle keys stored in user_stats table
if (in_array($key, self::USER_STATS_KEYS, true)) {
return $this->getUserStatsValue($userId, $key);
// Handle keys stored in forum_users table
if (in_array($key, self::FORUM_USER_KEYS, true)) {
return $this->getForumUserValue($userId, $key);
}
$default = self::DEFAULTS[$key] ?? null;
@@ -127,9 +127,9 @@ class UserPreferencesService {
throw new \InvalidArgumentException("Invalid preference key: $key");
}
// Handle keys stored in user_stats table
if (in_array($key, self::USER_STATS_KEYS, true)) {
$this->setUserStatsValue($userId, $key, $value);
// Handle keys stored in forum_users table
if (in_array($key, self::FORUM_USER_KEYS, true)) {
$this->setForumUserValue($userId, $key, $value);
return;
}
@@ -170,17 +170,17 @@ class UserPreferencesService {
}
/**
* Get a value from user_stats table
* Get a value from forum_users table
*
* @param string $userId The user ID
* @param string $key The preference key
* @return mixed The value
*/
private function getUserStatsValue(string $userId, string $key): mixed {
private function getForumUserValue(string $userId, string $key): mixed {
try {
$stats = $this->userStatsMapper->find($userId);
$forumUser = $this->forumUserMapper->find($userId);
return match ($key) {
self::PREF_SIGNATURE => $stats->getSignature() ?? '',
self::PREF_SIGNATURE => $forumUser->getSignature() ?? '',
default => self::DEFAULTS[$key] ?? null,
};
} catch (DoesNotExistException $e) {
@@ -189,21 +189,21 @@ class UserPreferencesService {
}
/**
* Set a value in user_stats table
* Set a value in forum_users table
*
* @param string $userId The user ID
* @param string $key The preference key
* @param mixed $value The value to set
*/
private function setUserStatsValue(string $userId, string $key, mixed $value): void {
$stats = $this->userStatsMapper->createOrUpdate($userId);
private function setForumUserValue(string $userId, string $key, mixed $value): void {
$forumUser = $this->forumUserMapper->createOrUpdate($userId);
match ($key) {
self::PREF_SIGNATURE => $stats->setSignature((string)$value),
self::PREF_SIGNATURE => $forumUser->setSignature((string)$value),
default => null,
};
$stats->setUpdatedAt(time());
$this->userStatsMapper->update($stats);
$forumUser->setUpdatedAt(time());
$this->forumUserMapper->update($forumUser);
}
}

View File

@@ -8,9 +8,9 @@ declare(strict_types=1);
namespace OCA\Forum\Service;
use OCA\Forum\Db\BBCodeMapper;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\UserRoleMapper;
use OCA\Forum\Db\UserStatsMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IL10N;
use OCP\IUserManager;
@@ -22,7 +22,7 @@ use OCP\IUserManager;
class UserService {
public function __construct(
private IUserManager $userManager,
private UserStatsMapper $userStatsMapper,
private ForumUserMapper $forumUserMapper,
private RoleMapper $roleMapper,
private UserRoleMapper $userRoleMapper,
private BBCodeMapper $bbCodeMapper,
@@ -47,7 +47,7 @@ class UserService {
/**
* Check if a user has been deleted
* Checks both Nextcloud user existence and user_stats deleted_at flag
* Checks both Nextcloud user existence and forum_users deleted_at flag
*/
public function isUserDeleted(string $userId): bool {
// First check if user exists in Nextcloud
@@ -56,12 +56,12 @@ class UserService {
return true;
}
// Check if marked as deleted in user_stats
// Check if marked as deleted in forum_users
try {
$stats = $this->userStatsMapper->find($userId);
return $stats->getDeletedAt() !== null;
$forumUser = $this->forumUserMapper->find($userId);
return $forumUser->getDeletedAt() !== null;
} catch (DoesNotExistException $e) {
// No stats record, user is not deleted (just hasn't posted yet)
// No forum user record, user is not deleted (just hasn't posted yet)
return false;
}
}
@@ -92,12 +92,12 @@ class UserService {
}
}
// Get signature from user stats
// Get signature from forum user
$signatureRaw = null;
$signature = null;
try {
$stats = $this->userStatsMapper->find($userId);
$signatureRaw = $stats->getSignature();
$forumUser = $this->forumUserMapper->find($userId);
$signatureRaw = $forumUser->getSignature();
if ($signatureRaw !== null && $signatureRaw !== '') {
// Parse BBCode in signature
if ($bbcodes === null) {
@@ -106,7 +106,7 @@ class UserService {
$signature = $this->bbCodeService->parse($signatureRaw, $bbcodes);
}
} catch (DoesNotExistException $e) {
// No stats record, no signature
// No forum user record, no signature
}
return [
@@ -135,7 +135,7 @@ class UserService {
$rolesMap = $this->fetchRolesForUsers($userIds);
}
// Fetch all user stats at once for signatures
// Fetch all forum users at once for signatures
$signaturesMap = $this->fetchSignaturesForUsers($userIds);
// Fetch BBCodes once for parsing all signatures (if not provided)
@@ -237,12 +237,12 @@ class UserService {
$signaturesMap[$userId] = null;
}
// Fetch all user stats for these users
$userStats = $this->userStatsMapper->findByUserIds($userIds);
// Fetch all forum users for these users
$forumUsers = $this->forumUserMapper->findByUserIds($userIds);
// Extract signatures
foreach ($userStats as $stats) {
$signaturesMap[$stats->getUserId()] = $stats->getSignature();
foreach ($forumUsers as $forumUser) {
$signaturesMap[$forumUser->getUserId()] = $forumUser->getSignature();
}
return $signaturesMap;

View File

@@ -3116,7 +3116,7 @@
"/ocs/v2.php/apps/forum/api/users": {
"get": {
"operationId": "forum_user-index",
"summary": "Get all user statistics",
"summary": "Get all forum users",
"tags": [
"forum_user"
],
@@ -3143,7 +3143,7 @@
],
"responses": {
"200": {
"description": "User statistics returned",
"description": "Forum users returned",
"content": {
"application/json": {
"schema": {
@@ -3182,7 +3182,7 @@
},
"post": {
"operationId": "forum_user-create",
"summary": "Create user stats Note: This is typically not needed as stats are auto-created on first post",
"summary": "Create forum user Note: This is typically not needed as forum users are auto-created on first post",
"tags": [
"forum_user"
],
@@ -3227,7 +3227,7 @@
],
"responses": {
"201": {
"description": "User stats created",
"description": "Forum user created",
"content": {
"application/json": {
"schema": {
@@ -3293,7 +3293,7 @@
"/ocs/v2.php/apps/forum/api/users/{userId}": {
"get": {
"operationId": "forum_user-show",
"summary": "Get user statistics by Nextcloud user ID Special case: use \"me\" to get current user stats",
"summary": "Get forum user by Nextcloud user ID Special case: use \"me\" to get current forum user",
"tags": [
"forum_user"
],
@@ -3329,7 +3329,7 @@
],
"responses": {
"200": {
"description": "User stats returned",
"description": "Forum user returned",
"content": {
"application/json": {
"schema": {
@@ -3362,7 +3362,7 @@
}
},
"404": {
"description": "User has no stats (hasn't posted yet) or guest user accessing \"me\"",
"description": "Forum user not found (user has not posted yet) or guest user accessing \"me\"",
"content": {
"application/json": {
"schema": {
@@ -8997,7 +8997,7 @@
"tags": [
{
"name": "forum_user",
"description": "Controller for user statistics Note: User stats are automatically created on first post/thread"
"description": "Controller for forum users Note: Forum users are automatically created on first post/thread"
}
]
}

View File

@@ -1,38 +1,38 @@
import { ref, computed, type Ref } from 'vue'
import { ocs } from '@/axios'
import type { UserStats } from '@/types'
import type { ForumUser } from '@/types'
import { getCurrentUser } from '@nextcloud/auth'
const currentUserStats = ref<UserStats | null>(null)
const currentForumUser = ref<ForumUser | null>(null)
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
const loaded = ref<boolean>(false)
export function useCurrentUser() {
const fetchCurrentUser = async (force = false): Promise<UserStats | null> => {
const fetchCurrentUser = async (force = false): Promise<ForumUser | null> => {
// Don't refetch if already loaded unless forced
if (loaded.value && !force) {
return currentUserStats.value
return currentForumUser.value
}
try {
loading.value = true
error.value = null
const response = await ocs.get<UserStats>('/users/me')
currentUserStats.value = response.data
const response = await ocs.get<ForumUser>('/users/me')
currentForumUser.value = response.data
loaded.value = true
return currentUserStats.value
return currentForumUser.value
} catch (e: unknown) {
// If 404, user stats don't exist yet (user hasn't posted) - this is OK
// If 404, forum user doesn't exist yet (user hasn't posted) - this is OK
const err = e as { response?: { status?: number } }
if (err?.response?.status === 404) {
console.debug('User stats not found - user has not posted yet')
currentUserStats.value = null
console.debug('Forum user not found - user has not posted yet')
currentForumUser.value = null
loaded.value = true
return null
}
console.error('Failed to fetch current user stats', e)
console.error('Failed to fetch current forum user', e)
error.value = (e as Error).message || 'Failed to load user information'
return null
} finally {
@@ -40,12 +40,12 @@ export function useCurrentUser() {
}
}
const refresh = async (): Promise<UserStats | null> => {
const refresh = async (): Promise<ForumUser | null> => {
return fetchCurrentUser(true)
}
const clear = (): void => {
currentUserStats.value = null
currentForumUser.value = null
loaded.value = false
error.value = null
}
@@ -60,7 +60,7 @@ export function useCurrentUser() {
)
return {
currentUserStats: currentUserStats as Ref<UserStats | null>,
currentForumUser: currentForumUser as Ref<ForumUser | null>,
loading: loading as Ref<boolean>,
error: error as Ref<string | null>,
loaded: loaded as Ref<boolean>,

View File

@@ -80,7 +80,7 @@ export interface Post {
}>
}
export interface UserStats {
export interface ForumUser {
userId: string
postCount: number
threadCount: number

View File

@@ -60,23 +60,23 @@
<div class="user-info">
<h2 class="user-name">{{ displayName }}</h2>
<div class="user-meta">
<span v-if="userStats && userStats.createdAt" class="meta-item">
<span v-if="forumUser && forumUser.createdAt" class="meta-item">
<span class="meta-label">{{ strings.firstPost }}</span>
<NcDateTime :timestamp="userStats.createdAt * 1000" />
<NcDateTime :timestamp="forumUser.createdAt * 1000" />
</span>
<span v-if="userStats && userStats.createdAt" class="meta-divider">·</span>
<span v-if="forumUser && forumUser.createdAt" class="meta-divider">·</span>
<span class="meta-item">
<span class="meta-label">{{
strings.threadsLabel(userStats?.threadCount || 0)
strings.threadsLabel(forumUser?.threadCount || 0)
}}</span>
<span class="meta-value">{{ userStats?.threadCount || 0 }}</span>
<span class="meta-value">{{ forumUser?.threadCount || 0 }}</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<span class="meta-label">{{
strings.repliesLabel(userStats?.postCount || 0)
strings.repliesLabel(forumUser?.postCount || 0)
}}</span>
<span class="meta-value">{{ userStats?.postCount || 0 }}</span>
<span class="meta-value">{{ forumUser?.postCount || 0 }}</span>
</span>
</div>
</div>
@@ -170,7 +170,7 @@ import PageWrapper from '@/components/PageWrapper.vue'
import ThreadCard from '@/components/ThreadCard.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import RefreshIcon from '@icons/Refresh.vue'
import type { UserStats, Thread, Post } from '@/types'
import type { ForumUser, Thread, Post } from '@/types'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
import { getCurrentUser } from '@nextcloud/auth'
@@ -195,7 +195,7 @@ export default defineComponent({
loading: false,
loadingThreads: false,
loadingPosts: false,
userStats: null as UserStats | null,
forumUser: null as ForumUser | null,
displayName: '',
threads: [] as Thread[],
posts: [] as Post[],
@@ -252,13 +252,13 @@ export default defineComponent({
// Load user stats (may not exist if user hasn't posted)
try {
const userResponse = await ocs.get(`/users/${this.userId}`)
this.userStats = userResponse.data
this.forumUser = userResponse.data
} catch (err: any) {
// 404 is OK - user hasn't posted yet
if (err.response?.status !== 404) {
throw err
}
this.userStats = null
this.forumUser = null
}
// Load both tabs on initial load for accurate counts

View File

@@ -6,8 +6,8 @@ namespace OCA\Forum\Tests\Controller;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Controller\ForumUserController;
use OCA\Forum\Db\UserStats;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Db\ForumUser;
use OCA\Forum\Db\ForumUserMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\IRequest;
@@ -18,21 +18,21 @@ use Psr\Log\LoggerInterface;
class ForumUserControllerTest extends TestCase {
private ForumUserController $controller;
private UserStatsMapper $userStatsMapper;
private ForumUserMapper $forumUserMapper;
private IUserSession $userSession;
private LoggerInterface $logger;
private IRequest $request;
protected function setUp(): void {
$this->request = $this->createMock(IRequest::class);
$this->userStatsMapper = $this->createMock(UserStatsMapper::class);
$this->forumUserMapper = $this->createMock(ForumUserMapper::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->controller = new ForumUserController(
Application::APP_ID,
$this->request,
$this->userStatsMapper,
$this->forumUserMapper,
$this->userSession,
$this->logger
);
@@ -42,7 +42,7 @@ class ForumUserControllerTest extends TestCase {
$user1 = $this->createForumUser(1, 'user1', 10);
$user2 = $this->createForumUser(2, 'user2', 25);
$this->userStatsMapper->expects($this->once())
$this->forumUserMapper->expects($this->once())
->method('findAll')
->willReturn([$user1, $user2]);
@@ -58,7 +58,7 @@ class ForumUserControllerTest extends TestCase {
$nextcloudUserId = 'user1';
$user = $this->createForumUser(1, $nextcloudUserId, 10);
$this->userStatsMapper->expects($this->once())
$this->forumUserMapper->expects($this->once())
->method('find')
->with($nextcloudUserId)
->willReturn($user);
@@ -74,15 +74,15 @@ class ForumUserControllerTest extends TestCase {
public function testShowReturnsNotFoundWhenUserDoesNotExist(): void {
$nextcloudUserId = 'non-existent-user';
$this->userStatsMapper->expects($this->once())
$this->forumUserMapper->expects($this->once())
->method('find')
->with($nextcloudUserId)
->willThrowException(new DoesNotExistException('User stats not found'));
->willThrowException(new DoesNotExistException('Forum user not found'));
$response = $this->controller->show($nextcloudUserId);
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
$this->assertEquals(['error' => 'User stats not found'], $response->getData());
$this->assertEquals(['error' => 'Forum user not found'], $response->getData());
}
public function testShowWithMeReturnsCurrentUserSuccessfully(): void {
@@ -93,7 +93,7 @@ class ForumUserControllerTest extends TestCase {
$user->method('getUID')->willReturn($nextcloudUserId);
$this->userSession->method('getUser')->willReturn($user);
$this->userStatsMapper->expects($this->once())
$this->forumUserMapper->expects($this->once())
->method('find')
->with($nextcloudUserId)
->willReturn($forumUser);
@@ -111,7 +111,7 @@ class ForumUserControllerTest extends TestCase {
$response = $this->controller->show('me');
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
$this->assertEquals(['error' => 'User stats not found'], $response->getData());
$this->assertEquals(['error' => 'Forum user not found'], $response->getData());
}
public function testShowWithMeReturnsNotFoundWhenForumUserDoesNotExist(): void {
@@ -121,22 +121,22 @@ class ForumUserControllerTest extends TestCase {
$user->method('getUID')->willReturn($nextcloudUserId);
$this->userSession->method('getUser')->willReturn($user);
$this->userStatsMapper->expects($this->once())
$this->forumUserMapper->expects($this->once())
->method('find')
->with($nextcloudUserId)
->willThrowException(new DoesNotExistException('User stats not found'));
->willThrowException(new DoesNotExistException('Forum user not found'));
$response = $this->controller->show('me');
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
$this->assertEquals(['error' => 'User stats not found'], $response->getData());
$this->assertEquals(['error' => 'Forum user not found'], $response->getData());
}
public function testCreateForumUserSuccessfully(): void {
$nextcloudUserId = 'new-user';
$createdUser = $this->createForumUser(1, $nextcloudUserId, 0);
$this->userStatsMapper->expects($this->once())
$this->forumUserMapper->expects($this->once())
->method('createOrUpdate')
->with($nextcloudUserId)
->willReturn($createdUser);
@@ -149,13 +149,13 @@ class ForumUserControllerTest extends TestCase {
$this->assertEquals(0, $data['postCount']);
}
private function createForumUser(int $id, string $userId, int $postCount): UserStats {
$userStats = new UserStats();
$userStats->setId($id);
$userStats->setUserId($userId);
$userStats->setPostCount($postCount);
$userStats->setCreatedAt(time());
$userStats->setUpdatedAt(time());
return $userStats;
private function createForumUser(int $id, string $userId, int $postCount): ForumUser {
$forumUser = new ForumUser();
$forumUser->setId($id);
$forumUser->setUserId($userId);
$forumUser->setPostCount($postCount);
$forumUser->setCreatedAt(time());
$forumUser->setUpdatedAt(time());
return $forumUser;
}
}

View File

@@ -10,6 +10,8 @@ use OCA\Forum\Db\BBCode;
use OCA\Forum\Db\BBCodeMapper;
use OCA\Forum\Db\Category;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\ForumUser;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Db\Post;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\Reaction;
@@ -18,8 +20,6 @@ use OCA\Forum\Db\ReadMarker;
use OCA\Forum\Db\ReadMarkerMapper;
use OCA\Forum\Db\Thread;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\UserStats;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\BBCodeService;
use OCA\Forum\Service\NotificationService;
use OCA\Forum\Service\PermissionService;
@@ -38,7 +38,7 @@ class PostControllerTest extends TestCase {
private PostMapper $postMapper;
private ThreadMapper $threadMapper;
private CategoryMapper $categoryMapper;
private UserStatsMapper $userStatsMapper;
private ForumUserMapper $forumUserMapper;
private ReactionMapper $reactionMapper;
private BBCodeService $bbCodeService;
private BBCodeMapper $bbCodeMapper;
@@ -56,7 +56,7 @@ class PostControllerTest extends TestCase {
$this->postMapper = $this->createMock(PostMapper::class);
$this->threadMapper = $this->createMock(ThreadMapper::class);
$this->categoryMapper = $this->createMock(CategoryMapper::class);
$this->userStatsMapper = $this->createMock(UserStatsMapper::class);
$this->forumUserMapper = $this->createMock(ForumUserMapper::class);
$this->reactionMapper = $this->createMock(ReactionMapper::class);
$this->bbCodeService = $this->createMock(BBCodeService::class);
$this->bbCodeMapper = $this->createMock(BBCodeMapper::class);
@@ -83,7 +83,7 @@ class PostControllerTest extends TestCase {
$this->postMapper,
$this->threadMapper,
$this->categoryMapper,
$this->userStatsMapper,
$this->forumUserMapper,
$this->reactionMapper,
$this->bbCodeService,
$this->bbCodeMapper,
@@ -226,7 +226,7 @@ class PostControllerTest extends TestCase {
$this->userSession->method('getUser')->willReturn($user);
// Mock forum user
$forumUser = new UserStats();
$forumUser = new ForumUser();
$forumUser->setId(1);
$forumUser->setUserId($userId);
$forumUser->setPostCount(0);
@@ -279,8 +279,8 @@ class PostControllerTest extends TestCase {
->method('update')
->willReturn($category);
// Mock user stats increment (void methods, no return value expectations)
$this->userStatsMapper->expects($this->once())
// Mock forum user increment (void methods, no return value expectations)
$this->forumUserMapper->expects($this->once())
->method('incrementPostCount')
->with($userId);
@@ -596,7 +596,7 @@ class PostControllerTest extends TestCase {
return $updatedCategory;
});
$this->userStatsMapper->expects($this->once())
$this->forumUserMapper->expects($this->once())
->method('incrementPostCount')
->with($userId);
@@ -672,7 +672,7 @@ class PostControllerTest extends TestCase {
return $updatedCategory;
});
$this->userStatsMapper->expects($this->once())
$this->forumUserMapper->expects($this->once())
->method('decrementPostCount')
->with($userId);
@@ -741,11 +741,11 @@ class PostControllerTest extends TestCase {
->method('update');
// First post deletion should decrement thread count, not post count
$this->userStatsMapper->expects($this->once())
$this->forumUserMapper->expects($this->once())
->method('decrementThreadCount')
->with($userId);
$this->userStatsMapper->expects($this->never())
$this->forumUserMapper->expects($this->never())
->method('decrementPostCount');
$response = $this->controller->destroy($postId);

View File

@@ -8,13 +8,13 @@ use OCA\Forum\AppInfo\Application;
use OCA\Forum\Controller\ThreadController;
use OCA\Forum\Db\Category;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\ForumUser;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Db\Post;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\Thread;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCA\Forum\Db\UserStats;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\PermissionService;
use OCA\Forum\Service\ThreadEnrichmentService;
use OCA\Forum\Service\UserPreferencesService;
@@ -32,7 +32,7 @@ class ThreadControllerTest extends TestCase {
private ThreadMapper $threadMapper;
private CategoryMapper $categoryMapper;
private PostMapper $postMapper;
private UserStatsMapper $userStatsMapper;
private ForumUserMapper $forumUserMapper;
private ThreadSubscriptionMapper $threadSubscriptionMapper;
private ThreadEnrichmentService $threadEnrichmentService;
private UserPreferencesService $userPreferencesService;
@@ -47,7 +47,7 @@ class ThreadControllerTest extends TestCase {
$this->threadMapper = $this->createMock(ThreadMapper::class);
$this->categoryMapper = $this->createMock(CategoryMapper::class);
$this->postMapper = $this->createMock(PostMapper::class);
$this->userStatsMapper = $this->createMock(UserStatsMapper::class);
$this->forumUserMapper = $this->createMock(ForumUserMapper::class);
$this->threadSubscriptionMapper = $this->createMock(ThreadSubscriptionMapper::class);
$this->threadEnrichmentService = $this->createMock(ThreadEnrichmentService::class);
$this->userPreferencesService = $this->createMock(UserPreferencesService::class);
@@ -73,7 +73,7 @@ class ThreadControllerTest extends TestCase {
$this->threadMapper,
$this->categoryMapper,
$this->postMapper,
$this->userStatsMapper,
$this->forumUserMapper,
$this->threadSubscriptionMapper,
$this->threadEnrichmentService,
$this->userPreferencesService,
@@ -222,14 +222,14 @@ class ThreadControllerTest extends TestCase {
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$forumUser = new UserStats();
$forumUser = new ForumUser();
$forumUser->setUserId($userId);
$forumUser->setPostCount(10);
// Mock user stats increment methods (first post doesn't count, only thread count increments)
$this->userStatsMapper->expects($this->never())
// Mock forum user increment methods (first post doesn't count, only thread count increments)
$this->forumUserMapper->expects($this->never())
->method('incrementPostCount');
$this->userStatsMapper->expects($this->once())
$this->forumUserMapper->expects($this->once())
->method('incrementThreadCount');
// Mock thread subscription
@@ -311,7 +311,7 @@ class ThreadControllerTest extends TestCase {
$this->assertEquals(['error' => 'User not authenticated'], $response->getData());
}
public function testCreateThreadReturnsForbiddenWhenUserStatsNotRegistered(): void {
public function testCreateThreadSucceedsEvenWhenForumUserUpdateFails(): void {
$categoryId = 1;
$title = 'New Thread';
$content = 'Initial post content';
@@ -321,11 +321,11 @@ class ThreadControllerTest extends TestCase {
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
// Mock user stats methods to throw exceptions (simulating user stats failure)
// Mock forum user methods to throw exceptions (simulating forum user update failure)
// The controller catches these and just logs warnings, so thread creation should still succeed
$this->userStatsMapper->method('incrementPostCount')
$this->forumUserMapper->method('incrementPostCount')
->willThrowException(new \Exception('Failed to increment post count'));
$this->userStatsMapper->method('incrementThreadCount')
$this->forumUserMapper->method('incrementThreadCount')
->willThrowException(new \Exception('Failed to increment thread count'));
// Mock thread subscription
@@ -348,7 +348,7 @@ class ThreadControllerTest extends TestCase {
$response = $this->controller->create($categoryId, $title, $content);
// Thread creation should succeed even if user stats fail (they're in a try-catch)
// Thread creation should succeed even if forum user update fails (they're in a try-catch)
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
}

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace OCA\Forum\Tests\Service;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Service\UserPreferencesService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IConfig;
@@ -15,21 +15,21 @@ use Psr\Log\LoggerInterface;
class UserPreferencesServiceTest extends TestCase {
private UserPreferencesService $service;
private IConfig $config;
private UserStatsMapper $userStatsMapper;
private ForumUserMapper $forumUserMapper;
private LoggerInterface $logger;
protected function setUp(): void {
$this->config = $this->createMock(IConfig::class);
$this->userStatsMapper = $this->createMock(UserStatsMapper::class);
$this->forumUserMapper = $this->createMock(ForumUserMapper::class);
$this->logger = $this->createMock(LoggerInterface::class);
// By default, mock no user stats (no signature)
$this->userStatsMapper->method('find')
// By default, mock no forum user (no signature)
$this->forumUserMapper->method('find')
->willThrowException(new DoesNotExistException(''));
$this->service = new UserPreferencesService(
$this->config,
$this->userStatsMapper,
$this->forumUserMapper,
$this->logger
);
}
@@ -37,7 +37,7 @@ class UserPreferencesServiceTest extends TestCase {
public function testGetAllPreferencesReturnsAllPreferences(): void {
$userId = 'user1';
// Only config-based preferences (signature is from user_stats)
// Only config-based preferences (signature is from forum_users)
$this->config->expects($this->exactly(2))
->method('getUserValue')
->willReturnCallback(function ($uid, $appId, $key, $default) use ($userId) {