feat: remove forum_users table

This commit is contained in:
2025-11-10 15:00:33 +02:00
parent 3f99ec854b
commit 522a49685f
17 changed files with 568 additions and 722 deletions

View File

@@ -9,16 +9,18 @@ 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\UserService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
@@ -26,11 +28,13 @@ class AdminController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private ForumUserMapper $forumUserMapper,
private UserStatsMapper $userStatsMapper,
private UserService $userService,
private ThreadMapper $threadMapper,
private PostMapper $postMapper,
private CategoryMapper $categoryMapper,
private UserRoleMapper $userRoleMapper,
private IUserManager $userManager,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -55,19 +59,19 @@ class AdminController extends OCSController {
}
// Get total counts
$totalUsers = $this->forumUserMapper->countAll();
$totalUsers = $this->userStatsMapper->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->forumUserMapper->countSince($weekAgo);
$recentUsers = $this->userStatsMapper->countSince($weekAgo);
$recentThreads = $this->threadMapper->countSince($weekAgo);
$recentPosts = $this->postMapper->countSince($weekAgo);
// Get top contributors (users with most posts)
$topContributors = $this->forumUserMapper->getTopContributors(5);
$topContributors = $this->userStatsMapper->getTopContributors(5);
return new DataResponse([
'totals' => [
@@ -101,22 +105,33 @@ 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
$forumUsers = $this->forumUserMapper->findAll();
// Enrich with role information
// Get all Nextcloud users and enrich with forum data
$enrichedUsers = [];
foreach ($forumUsers as $forumUser) {
$userId = $forumUser->getUserId();
$this->userManager->callForAllUsers(function ($user) use (&$enrichedUsers, $statsByUserId) {
$userId = $user->getUID();
// Get user display name
$userInfo = $this->userService->enrichUserData($userId);
// Get stats if they exist, otherwise use defaults
$stats = $statsByUserId[$userId] ?? null;
$userData = [
'id' => $forumUser->getId(),
'userId' => $userId,
'postCount' => $forumUser->getPostCount(),
'createdAt' => $forumUser->getCreatedAt(),
'updatedAt' => $forumUser->getUpdatedAt(),
'deletedAt' => $forumUser->getDeletedAt(),
'isDeleted' => $forumUser->getDeletedAt() !== null,
'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,
'isDeleted' => $userInfo['isDeleted'],
'roles' => [],
];
@@ -130,7 +145,7 @@ class AdminController extends OCSController {
}
$enrichedUsers[] = $userData;
}
});
return new DataResponse(['users' => $enrichedUsers]);
} catch (\Exception $e) {

View File

@@ -7,7 +7,7 @@ declare(strict_types=1);
namespace OCA\Forum\Controller;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Db\UserStatsMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -18,11 +18,15 @@ use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* Controller for user statistics
* Note: User stats are automatically created on first post/thread
*/
class ForumUserController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private ForumUserMapper $forumUserMapper,
private UserStatsMapper $userStatsMapper,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -30,32 +34,34 @@ class ForumUserController extends OCSController {
}
/**
* Get all forum users
* Get all user statistics
*
* @return DataResponse<Http::STATUS_OK, list<array<string, mixed>>, array{}>
*
* 200: Forum users returned
* 200: User statistics returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/users')]
public function index(): DataResponse {
try {
$users = $this->forumUserMapper->findAll();
$users = $this->userStatsMapper->findAll();
return new DataResponse(array_map(fn ($u) => $u->jsonSerialize(), $users));
} catch (\Exception $e) {
$this->logger->error('Error fetching forum users: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch forum users'], Http::STATUS_INTERNAL_SERVER_ERROR);
$this->logger->error('Error fetching user stats: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch user stats'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get forum user by Nextcloud user ID
* Special case: use "me" to get current user
* Get user statistics by Nextcloud user ID
* Special case: use "me" to get current user stats
*
* @param string $userId Nextcloud user ID or "me" for current user
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: string}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{error: string}, array{}>
*
* 200: Forum user returned
* 200: User stats returned
* 401: User not authenticated (when using "me")
* 404: User has no stats (hasn't posted yet)
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/users/{userId}')]
@@ -70,112 +76,34 @@ class ForumUserController extends OCSController {
$userId = $currentUser->getUID();
}
$user = $this->forumUserMapper->findByUserId($userId);
return new DataResponse($user->jsonSerialize());
$stats = $this->userStatsMapper->find($userId);
return new DataResponse($stats->jsonSerialize());
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Forum user not found'], Http::STATUS_NOT_FOUND);
return new DataResponse(['error' => 'User stats not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error fetching forum user: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch forum user'], Http::STATUS_INTERNAL_SERVER_ERROR);
$this->logger->error('Error fetching user stats: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch user stats'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Create a new forum user
* Create user stats
* Note: This is typically not needed as stats are auto-created on first post
*
* @param string $userId Nextcloud user ID
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>
*
* 201: Forum user created
* 201: User stats created
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/users')]
public function create(string $userId): DataResponse {
try {
$forumUser = new \OCA\Forum\Db\ForumUser();
$forumUser->setUserId($userId);
$forumUser->setPostCount(0);
$forumUser->setCreatedAt(time());
$forumUser->setUpdatedAt(time());
/** @var \OCA\Forum\Db\ForumUser */
$createdUser = $this->forumUserMapper->insert($forumUser);
return new DataResponse($createdUser->jsonSerialize(), Http::STATUS_CREATED);
$stats = $this->userStatsMapper->createOrUpdate($userId);
return new DataResponse($stats->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error creating forum user: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to create forum user'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Update a forum user
*
* @param string $userId Nextcloud user ID or "me" for current user
* @param int|null $postCount Post count
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: Forum user updated
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'PUT', url: '/api/users/{userId}')]
public function update(string $userId, ?int $postCount = null): DataResponse {
try {
// Handle "me" as special case for current user
if ($userId === 'me') {
$currentUser = $this->userSession->getUser();
if (!$currentUser) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$userId = $currentUser->getUID();
}
$user = $this->forumUserMapper->findByUserId($userId);
if ($postCount !== null) {
$user->setPostCount($postCount);
}
$user->setUpdatedAt(time());
/** @var \OCA\Forum\Db\ForumUser */
$updatedUser = $this->forumUserMapper->update($user);
return new DataResponse($updatedUser->jsonSerialize());
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Forum user not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error updating forum user: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to update forum user'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Delete a forum user
*
* @param string $userId Nextcloud user ID or "me" for current user
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
*
* 200: Forum user deleted
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/users/{userId}')]
public function destroy(string $userId): DataResponse {
try {
// Handle "me" as special case for current user
if ($userId === 'me') {
$currentUser = $this->userSession->getUser();
if (!$currentUser) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$userId = $currentUser->getUID();
}
$user = $this->forumUserMapper->findByUserId($userId);
$this->forumUserMapper->delete($user);
return new DataResponse(['success' => true]);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Forum user not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error deleting forum user: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to delete forum user'], Http::STATUS_INTERNAL_SERVER_ERROR);
$this->logger->error('Error creating user stats: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to create user stats'], 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\PermissionService;
use OCP\AppFramework\Db\DoesNotExistException;
@@ -35,7 +35,7 @@ class PostController extends OCSController {
private PostMapper $postMapper,
private ThreadMapper $threadMapper,
private CategoryMapper $categoryMapper,
private ForumUserMapper $forumUserMapper,
private UserStatsMapper $userStatsMapper,
private ReactionMapper $reactionMapper,
private BBCodeService $bbCodeService,
private BBCodeMapper $bbCodeMapper,
@@ -207,14 +207,6 @@ class PostController extends OCSController {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
// Ensure forum user exists - do not auto-create
try {
$forumUser = $this->forumUserMapper->findByUserId($user->getUID());
} catch (DoesNotExistException $e) {
// User must be registered in the forum before posting
return new DataResponse(['error' => 'User not registered in forum'], Http::STATUS_FORBIDDEN);
}
// Auto-generate slug if not provided
if ($slug === null || $slug === '') {
$slug = 'post-' . uniqid();
@@ -257,14 +249,12 @@ class PostController extends OCSController {
// Don't fail the request if thread update fails
}
// Update the forum user's post count
// Update user stats post count (auto-creates stats if needed)
try {
$forumUser->setPostCount($forumUser->getPostCount() + 1);
$forumUser->setUpdatedAt(time());
$this->forumUserMapper->update($forumUser);
$this->userStatsMapper->incrementPostCount($user->getUID());
} catch (\Exception $e) {
$this->logger->warning('Failed to update forum user post count: ' . $e->getMessage());
// Don't fail the request if user update fails
$this->logger->warning('Failed to update user stats post count: ' . $e->getMessage());
// Don't fail the request if stats update fails
}
// Update the category's post count

View File

@@ -9,11 +9,11 @@ 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\UserStatsMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -31,7 +31,7 @@ class ThreadController extends OCSController {
private ThreadMapper $threadMapper,
private CategoryMapper $categoryMapper,
private PostMapper $postMapper,
private ForumUserMapper $forumUserMapper,
private UserStatsMapper $userStatsMapper,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -185,13 +185,6 @@ class ThreadController extends OCSController {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
// Ensure forum user exists
try {
$forumUser = $this->forumUserMapper->findByUserId($user->getUID());
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'User not registered in forum'], Http::STATUS_FORBIDDEN);
}
// Generate slug from title
$slug = $this->generateSlug($title);
@@ -243,13 +236,12 @@ class ThreadController extends OCSController {
$this->logger->warning('Failed to update category counts: ' . $e->getMessage());
}
// Update forum user's post count
// Update user stats (post count and thread count, auto-creates stats if needed)
try {
$forumUser->setPostCount($forumUser->getPostCount() + 1);
$forumUser->setUpdatedAt(time());
$this->forumUserMapper->update($forumUser);
$this->userStatsMapper->incrementPostCount($user->getUID());
$this->userStatsMapper->incrementThreadCount($user->getUID());
} catch (\Exception $e) {
$this->logger->warning('Failed to update forum user post count: ' . $e->getMessage());
$this->logger->warning('Failed to update user stats: ' . $e->getMessage());
}
return new DataResponse($createdThread->jsonSerialize(), Http::STATUS_CREATED);

View File

@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* @method int getId()
* @method void setId(int $value)
* @method string getUserId()
* @method void setUserId(string $value)
* @method int getPostCount()
* @method void setPostCount(int $value)
* @method int getCreatedAt()
* @method void setCreatedAt(int $value)
* @method int getUpdatedAt()
* @method void setUpdatedAt(int $value)
* @method int|null getDeletedAt()
* @method void setDeletedAt(?int $value)
*/
class ForumUser extends Entity implements JsonSerializable {
protected $userId;
protected $postCount;
protected $createdAt;
protected $updatedAt;
protected $deletedAt;
public function __construct() {
$this->addType('id', 'integer');
$this->addType('userId', 'string');
$this->addType('postCount', 'integer');
$this->addType('createdAt', 'integer');
$this->addType('updatedAt', 'integer');
$this->addType('deletedAt', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'userId' => $this->getUserId(),
'postCount' => $this->getPostCount(),
'createdAt' => $this->getCreatedAt(),
'updatedAt' => $this->getUpdatedAt(),
'deletedAt' => $this->getDeletedAt(),
'isDeleted' => $this->getDeletedAt() !== null,
];
}
/**
* Get the display name for this user
* Returns obfuscated name if user is deleted
*
* @return string
*/
public function getDisplayName(): string {
if ($this->getDeletedAt() !== null) {
// User is deleted, return obfuscated name
return 'Deleted User #' . $this->getId();
}
return $this->getUserId();
}
}

View File

@@ -1,117 +0,0 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Db;
use OCA\Forum\AppInfo\Application;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<ForumUser>
*/
class ForumUserMapper extends QBMapper {
public function __construct(
IDBConnection $db,
) {
parent::__construct($db, Application::tableName('forum_users'), ForumUser::class);
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function find(int $id): ForumUser {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()
->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))
);
return $this->findEntity($qb);
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function findByUserId(string $userId): ForumUser {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()
->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
);
return $this->findEntity($qb);
}
/**
* @return array<ForumUser>
*/
public function findAll(): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName());
return $this->findEntities($qb);
}
/**
* Count all forum users
*/
public function countAll(): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName());
$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();
return (int)($row['count'] ?? 0);
}
/**
* Count users created since a timestamp
*/
public function countSince(int $timestamp): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName())
->where($qb->expr()->gte('created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)));
$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();
return (int)($row['count'] ?? 0);
}
/**
* Get top contributors by post count
*
* @return array<array<string, mixed>>
*/
public function getTopContributors(int $limit = 5): array {
$qb = $this->db->getQueryBuilder();
$qb->select('user_id', 'post_count')
->from($this->getTableName())
->orderBy('post_count', 'DESC')
->setMaxResults($limit);
$result = $qb->executeQuery();
$contributors = [];
while ($row = $result->fetch()) {
$contributors[] = [
'userId' => $row['user_id'],
'postCount' => (int)$row['post_count'],
];
}
$result->closeCursor();
return $contributors;
}
}

View File

@@ -96,16 +96,10 @@ class Post extends Entity implements JsonSerializable {
$post['content'] = $service->parse($post['content'], $bbcodes);
// Add author display name (obfuscated if user is deleted)
try {
$forumUserMapper = \OC::$server->get(\OCA\Forum\Db\ForumUserMapper::class);
$forumUser = $forumUserMapper->findByUserId($post['authorId']);
$post['authorDisplayName'] = $forumUser->getDisplayName();
$post['authorIsDeleted'] = $forumUser->getDeletedAt() !== null;
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
// Forum user doesn't exist, use the original authorId
$post['authorDisplayName'] = $post['authorId'];
$post['authorIsDeleted'] = false;
}
$userService = \OC::$server->get(\OCA\Forum\Service\UserService::class);
$userData = $userService->enrichUserData($post['authorId']);
$post['authorDisplayName'] = $userData['displayName'];
$post['authorIsDeleted'] = $userData['isDeleted'];
// Add reactions (grouped by emoji)
$post['reactions'] = self::groupReactions($reactions, $currentUserId);

View File

@@ -97,16 +97,10 @@ class Thread extends Entity implements JsonSerializable {
}
// Add author display name (obfuscated if user is deleted)
try {
$forumUserMapper = \OC::$server->get(\OCA\Forum\Db\ForumUserMapper::class);
$forumUser = $forumUserMapper->findByUserId($thread['authorId']);
$thread['authorDisplayName'] = $forumUser->getDisplayName();
$thread['authorIsDeleted'] = $forumUser->getDeletedAt() !== null;
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
// Forum user doesn't exist, use the original authorId
$thread['authorDisplayName'] = $thread['authorId'];
$thread['authorIsDeleted'] = false;
}
$userService = \OC::$server->get(\OCA\Forum\Service\UserService::class);
$userData = $userService->enrichUserData($thread['authorId']);
$thread['authorDisplayName'] = $userData['displayName'];
$thread['authorIsDeleted'] = $userData['isDeleted'];
// Add category information (slug and name) for navigation
try {

198
lib/Db/UserStatsMapper.php Normal file
View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Db;
use OCA\Forum\AppInfo\Application;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<UserStats>
*/
class UserStatsMapper extends QBMapper {
public function __construct(
IDBConnection $db,
) {
parent::__construct($db, Application::tableName('user_stats'), UserStats::class);
}
/**
* Find user stats by user ID
*
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function find(string $userId): UserStats {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
);
return $this->findEntity($qb);
}
/**
* Find all user stats
*
* @return array<UserStats>
*/
public function findAll(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->orderBy('post_count', 'DESC');
return $this->findEntities($qb);
}
/**
* Create or update user stats (upsert pattern)
* This is used when we need to ensure stats exist for a user
*/
public function createOrUpdate(string $userId): UserStats {
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);
}
}
/**
* Increment post count for a user
* Auto-creates stats 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);
}
/**
* Increment thread count for a user
* Auto-creates stats 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);
}
/**
* Decrement post count for a user
*/
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);
} catch (DoesNotExistException $e) {
// User stats don't exist, nothing to decrement
}
}
/**
* Decrement thread count for a user
*/
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);
} catch (DoesNotExistException $e) {
// User stats don't exist, nothing to decrement
}
}
/**
* Get top contributors by post count
*
* @return array<array{userId: string, postCount: int}>
*/
public function getTopContributors(int $limit = 10): array {
$qb = $this->db->getQueryBuilder();
$qb->select('user_id', 'post_count')
->from($this->getTableName())
->where($qb->expr()->isNull('deleted_at'))
->orderBy('post_count', 'DESC')
->setMaxResults($limit);
$result = $qb->executeQuery();
$rows = $result->fetchAll();
$result->closeCursor();
return array_map(fn ($row) => [
'userId' => $row['user_id'],
'postCount' => (int)$row['post_count'],
], $rows);
}
/**
* Count all users (excluding deleted)
*/
public function countAll(): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName())
->where($qb->expr()->isNull('deleted_at'));
$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();
return (int)($row['count'] ?? 0);
}
/**
* Count users who posted since a timestamp
*/
public function countSince(int $timestamp): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName())
->where($qb->expr()->gte('created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->isNull('deleted_at'));
$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();
return (int)($row['count'] ?? 0);
}
/**
* Mark a user as deleted
*/
public function markDeleted(string $userId): void {
try {
$stats = $this->find($userId);
$stats->setDeletedAt(time());
$stats->setUpdatedAt(time());
$this->update($stats);
} 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);
}
}
}

View File

@@ -7,58 +7,25 @@ declare(strict_types=1);
namespace OCA\Forum\Listener;
use OCA\Forum\Db\ForumUser;
use OCA\Forum\Db\ForumUserMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCA\Forum\Db\UserStatsMapper;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\User\Events\UserChangedEvent;
use OCP\User\Events\UserCreatedEvent;
use OCP\User\Events\UserDeletedEvent;
use Psr\Log\LoggerInterface;
/**
* @template-implements IEventListener<UserCreatedEvent|UserDeletedEvent|UserChangedEvent>
* @template-implements IEventListener<UserDeletedEvent>
*/
class UserEventListener implements IEventListener {
public function __construct(
private ForumUserMapper $forumUserMapper,
private UserStatsMapper $userStatsMapper,
private LoggerInterface $logger,
) {
}
public function handle(Event $event): void {
if ($event instanceof UserCreatedEvent) {
$this->handleUserCreated($event);
} elseif ($event instanceof UserDeletedEvent) {
if ($event instanceof UserDeletedEvent) {
$this->handleUserDeleted($event);
} elseif ($event instanceof UserChangedEvent) {
$this->handleUserChanged($event);
}
}
private function handleUserCreated(UserCreatedEvent $event): void {
$user = $event->getUser();
$userId = $user->getUID();
try {
// Check if forum user already exists
$this->forumUserMapper->findByUserId($userId);
$this->logger->debug("Forum user already exists for Nextcloud user: {$userId}");
} catch (DoesNotExistException $e) {
// Create new forum user
$forumUser = new ForumUser();
$forumUser->setUserId($userId);
$forumUser->setPostCount(0);
$forumUser->setCreatedAt(time());
$forumUser->setUpdatedAt(time());
try {
$this->forumUserMapper->insert($forumUser);
$this->logger->info("Created forum user for Nextcloud user: {$userId}");
} catch (\Exception $ex) {
$this->logger->error("Failed to create forum user for {$userId}: " . $ex->getMessage());
}
}
}
@@ -67,59 +34,13 @@ class UserEventListener implements IEventListener {
$userId = $user->getUID();
try {
$forumUser = $this->forumUserMapper->findByUserId($userId);
// Soft delete: mark as deleted instead of removing the record
$forumUser->setDeletedAt(time());
$forumUser->setUpdatedAt(time());
$this->forumUserMapper->update($forumUser);
$this->logger->info("Soft-deleted forum user for Nextcloud user: {$userId}");
} catch (DoesNotExistException $e) {
// Forum user doesn't exist, nothing to delete
$this->logger->debug("No forum user found to delete for Nextcloud user: {$userId}");
// 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}");
} catch (\Exception $ex) {
$this->logger->error("Failed to soft-delete forum user for {$userId}: " . $ex->getMessage());
}
}
private function handleUserChanged(UserChangedEvent $event): void {
$user = $event->getUser();
$userId = $user->getUID();
$feature = $event->getFeature();
$value = $event->getValue();
try {
$forumUser = $this->forumUserMapper->findByUserId($userId);
// Update the updatedAt timestamp
$forumUser->setUpdatedAt(time());
// You can sync additional user properties here if needed in the future
// For example, if you add displayName or email fields to ForumUser:
// if ($feature === 'displayName') {
// $forumUser->setDisplayName($value);
// }
$this->forumUserMapper->update($forumUser);
$this->logger->debug("Updated forum user for Nextcloud user: {$userId}, feature: {$feature}");
} catch (DoesNotExistException $e) {
// Forum user doesn't exist yet, create it
$this->logger->debug("Forum user not found during update, creating for: {$userId}");
$forumUser = new ForumUser();
$forumUser->setUserId($userId);
$forumUser->setPostCount(0);
$forumUser->setCreatedAt(time());
$forumUser->setUpdatedAt(time());
try {
$this->forumUserMapper->insert($forumUser);
$this->logger->info("Created forum user during update for Nextcloud user: {$userId}");
} catch (\Exception $ex) {
$this->logger->error("Failed to create forum user during update for {$userId}: " . $ex->getMessage());
}
} catch (\Exception $ex) {
$this->logger->error("Failed to update forum user for {$userId}: " . $ex->getMessage());
// If stats don't exist, that's fine - user never posted
$this->logger->debug("No user stats found to delete for Nextcloud user: {$userId}");
}
}
}

View File

@@ -32,7 +32,7 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$schema = $schemaClosure();
$this->createForumRolesTable($schema);
$this->createForumUsersTable($schema);
$this->createUserStatsTable($schema);
$this->createForumUserRolesTable($schema);
$this->createForumCatHeadersTable($schema);
$this->createForumCategoriesTable($schema);
@@ -85,17 +85,12 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$table->addIndex(['name'], 'forum_roles_name_idx');
}
private function createForumUsersTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('forum_users')) {
private function createUserStatsTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('user_stats')) {
return;
}
$table = $schema->createTable('forum_users');
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]);
$table = $schema->createTable('user_stats');
$table->addColumn('user_id', 'string', [
'notnull' => true,
'length' => 64,
@@ -105,6 +100,21 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
'default' => 0,
'unsigned' => true,
]);
$table->addColumn('thread_count', 'integer', [
'notnull' => true,
'default' => 0,
'unsigned' => true,
]);
$table->addColumn('last_post_at', 'integer', [
'notnull' => false,
'unsigned' => true,
'default' => null,
]);
$table->addColumn('deleted_at', 'integer', [
'notnull' => false,
'unsigned' => true,
'default' => null,
]);
$table->addColumn('created_at', 'integer', [
'notnull' => true,
'unsigned' => true,
@@ -113,13 +123,9 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('deleted_at', 'integer', [
'notnull' => false,
'unsigned' => true,
'default' => null,
]);
$table->setPrimaryKey(['id']);
$table->addUniqueIndex(['user_id'], 'forum_users_user_id_idx');
$table->setPrimaryKey(['user_id']);
$table->addIndex(['post_count'], 'user_stats_post_count_idx');
$table->addIndex(['deleted_at'], 'user_stats_deleted_at_idx');
}
private function createForumUserRolesTable(ISchemaWrapper $schema): void {
@@ -707,7 +713,7 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
->executeStatement();
}
// Create forum users for all Nextcloud users
// Assign roles to all Nextcloud users
$groupManager = \OC::$server->get(\OCP\IGroupManager::class);
$adminGroup = $groupManager->get('admin');
@@ -715,17 +721,6 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
$userId = $user->getUID();
$isAdmin = $adminGroup && $adminGroup->inGroup($user);
// Create forum user
$qb = $db->getQueryBuilder();
$qb->insert('forum_users')
->values([
'user_id' => $qb->createNamedParameter($userId),
'post_count' => $qb->createNamedParameter($userId === 'admin' ? 1 : 0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
// Assign User role to all users
$qb = $db->getQueryBuilder();
$qb->insert('forum_user_roles')
@@ -808,5 +803,18 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
->set('last_post_id', $qb->createNamedParameter($postId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq('id', $qb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->executeStatement();
// Create user stats for admin (who created the welcome post/thread)
$qb = $db->getQueryBuilder();
$qb->insert('user_stats')
->values([
'user_id' => $qb->createNamedParameter('admin'),
'post_count' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'thread_count' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'last_post_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'updated_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Service;
use OCA\Forum\Db\UserStatsMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IUserManager;
/**
* Service for user enrichment and display logic
* Handles Nextcloud user lookups and deleted user display
*/
class UserService {
public function __construct(
private IUserManager $userManager,
private UserStatsMapper $userStatsMapper,
) {
}
/**
* Get display name for a user
* Returns "Deleted User" if user doesn't exist in Nextcloud
*/
public function getUserDisplayName(string $userId): string {
$user = $this->userManager->get($userId);
if ($user !== null) {
return $user->getDisplayName();
}
// User doesn't exist in Nextcloud - return generic deleted user name
return 'Deleted User';
}
/**
* Check if a user has been deleted
* Checks both Nextcloud user existence and user_stats deleted_at flag
*/
public function isUserDeleted(string $userId): bool {
// First check if user exists in Nextcloud
$user = $this->userManager->get($userId);
if ($user === null) {
return true;
}
// Check if marked as deleted in user_stats
try {
$stats = $this->userStatsMapper->find($userId);
return $stats->getDeletedAt() !== null;
} catch (DoesNotExistException $e) {
// No stats record, user is not deleted (just hasn't posted yet)
return false;
}
}
/**
* Enrich user data with display name and deleted status
*
* @return array{userId: string, displayName: string, isDeleted: bool}
*/
public function enrichUserData(string $userId): array {
$isDeleted = $this->isUserDeleted($userId);
$displayName = $this->getUserDisplayName($userId);
return [
'userId' => $userId,
'displayName' => $displayName,
'isDeleted' => $isDeleted,
];
}
/**
* Enrich multiple users at once (for performance)
*
* @param array<string> $userIds
* @return array<string, array{userId: string, displayName: string, isDeleted: bool}>
*/
public function enrichMultipleUsers(array $userIds): array {
$result = [];
foreach ($userIds as $userId) {
$result[$userId] = $this->enrichUserData($userId);
}
return $result;
}
}

View File

@@ -3333,7 +3333,7 @@
"/ocs/v2.php/apps/forum/api/users": {
"get": {
"operationId": "forum_user-index",
"summary": "Get all forum users",
"summary": "Get all user statistics",
"tags": [
"forum_user"
],
@@ -3359,7 +3359,7 @@
],
"responses": {
"200": {
"description": "Forum users returned",
"description": "User statistics returned",
"content": {
"application/json": {
"schema": {
@@ -3426,7 +3426,7 @@
},
"post": {
"operationId": "forum_user-create",
"summary": "Create a new forum user",
"summary": "Create user stats Note: This is typically not needed as stats are auto-created on first post",
"tags": [
"forum_user"
],
@@ -3471,7 +3471,7 @@
],
"responses": {
"201": {
"description": "Forum user created",
"description": "User stats created",
"content": {
"application/json": {
"schema": {
@@ -3537,7 +3537,7 @@
"/ocs/v2.php/apps/forum/api/users/{userId}": {
"get": {
"operationId": "forum_user-show",
"summary": "Get forum user by Nextcloud user ID Special case: use \"me\" to get current user",
"summary": "Get user statistics by Nextcloud user ID Special case: use \"me\" to get current user stats",
"tags": [
"forum_user"
],
@@ -3572,7 +3572,7 @@
],
"responses": {
"200": {
"description": "Forum user returned",
"description": "User stats returned",
"content": {
"application/json": {
"schema": {
@@ -3604,192 +3604,8 @@
}
}
},
"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": {}
}
}
}
}
}
}
}
}
},
"put": {
"operationId": "forum_user-update",
"summary": "Update a forum user",
"tags": [
"forum_user"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"postCount": {
"type": "integer",
"format": "int64",
"nullable": true,
"default": null,
"description": "Post count"
}
}
}
}
}
},
"parameters": [
{
"name": "userId",
"in": "path",
"description": "Nextcloud user ID or \"me\" for current user",
"required": true,
"schema": {
"type": "string"
}
},
{
"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": "Forum user updated",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"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": {}
}
}
}
}
}
}
}
}
},
"delete": {
"operationId": "forum_user-destroy",
"summary": "Delete a forum user",
"tags": [
"forum_user"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "userId",
"in": "path",
"description": "Nextcloud user ID or \"me\" for current user",
"required": true,
"schema": {
"type": "string"
}
},
{
"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": "Forum user deleted",
"404": {
"description": "User has no stats (hasn't posted yet)",
"content": {
"application/json": {
"schema": {
@@ -3811,11 +3627,11 @@
"data": {
"type": "object",
"required": [
"success"
"error"
],
"properties": {
"success": {
"type": "boolean"
"error": {
"type": "string"
}
}
}
@@ -3827,29 +3643,64 @@
}
},
"401": {
"description": "Current user is not logged in",
"description": "User not authenticated (when using \"me\")",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"anyOf": [
{
"type": "object",
"required": [
"meta",
"data"
"ocs"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
},
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
]
}
}
}
@@ -8141,5 +7992,10 @@
}
}
},
"tags": []
"tags": [
{
"name": "forum_user",
"description": "Controller for user statistics Note: User stats 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 { ForumUser } from '@/types'
import type { UserStats } from '@/types'
import { getCurrentUser } from '@nextcloud/auth'
const currentUser = ref<ForumUser | null>(null)
const currentUserStats = ref<UserStats | 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<ForumUser | null> => {
const fetchCurrentUser = async (force = false): Promise<UserStats | null> => {
// Don't refetch if already loaded unless forced
if (loaded.value && !force) {
return currentUser.value
return currentUserStats.value
}
try {
loading.value = true
error.value = null
const response = await ocs.get<ForumUser>('/users/me')
currentUser.value = response.data
const response = await ocs.get<UserStats>('/users/me')
currentUserStats.value = response.data
loaded.value = true
return currentUser.value
return currentUserStats.value
} catch (e: unknown) {
// If 404, user hasn't been created yet - this is OK, we'll use Nextcloud user info
// If 404, user stats don't exist yet (user hasn't posted) - this is OK
const err = e as { response?: { status?: number } }
if (err?.response?.status === 404) {
console.debug('Forum user not found')
currentUser.value = null
console.debug('User stats not found - user has not posted yet')
currentUserStats.value = null
loaded.value = true
return null
}
console.error('Failed to fetch current user', e)
console.error('Failed to fetch current user stats', 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<ForumUser | null> => {
const refresh = async (): Promise<UserStats | null> => {
return fetchCurrentUser(true)
}
const clear = (): void => {
currentUser.value = null
currentUserStats.value = null
loaded.value = false
error.value = null
}
@@ -58,7 +58,7 @@ export function useCurrentUser() {
const displayName = computed<string>(() => nextcloudUser.value?.displayName || nextcloudUser.value?.uid || 'Guest')
return {
currentUser: currentUser as Ref<ForumUser | null>,
currentUserStats: currentUserStats as Ref<UserStats | null>,
loading: loading as Ref<boolean>,
error: error as Ref<string | null>,
loaded: loaded as Ref<boolean>,

View File

@@ -72,14 +72,14 @@ export interface Post {
}>
}
export interface ForumUser {
id: number
export interface UserStats {
userId: string
postCount: number
threadCount: number
lastPostAt: number | null
deletedAt: number | null
createdAt: number
updatedAt: number
deletedAt: number | null
isDeleted: boolean
}
export interface BBCode {

View File

@@ -30,7 +30,7 @@
</NcEmptyContent>
<!-- Profile content -->
<div v-else-if="forumUser" class="profile-content mt-16">
<div v-else class="profile-content mt-16">
<!-- User Header -->
<div class="user-header">
<div class="user-avatar">
@@ -39,14 +39,19 @@
<div class="user-info">
<h2 class="user-name">{{ displayName }}</h2>
<div class="user-meta">
<span v-if="userStats && userStats.createdAt" class="meta-item">
<span class="meta-label">{{ strings.firstPost }}</span>
<NcDateTime :timestamp="userStats.createdAt * 1000" />
</span>
<span v-if="userStats && userStats.createdAt" class="meta-divider">·</span>
<span class="meta-item">
<span class="meta-label">{{ strings.joined }}</span>
<NcDateTime v-if="forumUser.createdAt" :timestamp="forumUser.createdAt * 1000" />
<span class="meta-label">{{ strings.threads }}</span>
<span class="meta-value">{{ userStats?.threadCount || 0 }}</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<span class="meta-label">{{ strings.posts }}</span>
<span class="meta-value">{{ forumUser.postCount }}</span>
<span class="meta-value">{{ userStats?.postCount || 0 }}</span>
</span>
</div>
</div>
@@ -135,7 +140,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import ThreadCard from '@/components/ThreadCard.vue'
import type { ForumUser, Thread, Post } from '@/types'
import type { UserStats, Thread, Post } from '@/types'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import { getCurrentUser } from '@nextcloud/auth'
@@ -156,7 +161,7 @@ export default defineComponent({
loading: false,
loadingThreads: false,
loadingPosts: false,
forumUser: null as ForumUser | null,
userStats: null as UserStats | null,
displayName: '',
threads: [] as Thread[],
posts: [] as Post[],
@@ -168,7 +173,7 @@ export default defineComponent({
loading: t('forum', 'Loading...'),
errorTitle: t('forum', 'Error'),
retry: t('forum', 'Retry'),
joined: t('forum', 'Joined'),
firstPost: t('forum', 'First post'),
posts: t('forum', 'Posts'),
threads: t('forum', 'Threads'),
replies: t('forum', 'Replies'),
@@ -206,13 +211,21 @@ export default defineComponent({
this.error = null
try {
// Load user data
const userResponse = await ocs.get(`/api/users/${this.userId}`)
this.forumUser = userResponse.data
// Get display name from Nextcloud
// Get display name from Nextcloud first
this.displayName = await this.getDisplayName(this.userId)
// Load user stats (may not exist if user hasn't posted)
try {
const userResponse = await ocs.get(`/api/users/${this.userId}`)
this.userStats = userResponse.data
} catch (err: any) {
// 404 is OK - user hasn't posted yet
if (err.response?.status !== 404) {
throw err
}
this.userStats = null
}
// Load initial tab data
if (this.activeTab === 'threads') {
await this.loadThreads()

View File

@@ -36,20 +36,30 @@
<div
v-for="user in users"
:key="user.id"
:key="user.userId"
class="table-row"
:class="{ 'is-deleted': user.isDeleted }"
>
<div class="col-user">
<NcAvatar :user="user.userId" :size="40" />
<div class="user-info">
<div class="user-name">{{ user.userId }}</div>
<div class="user-id muted">ID: {{ user.id }}</div>
<div class="user-name">{{ user.displayName }}</div>
<div class="user-id muted">@{{ user.userId }}</div>
</div>
</div>
<div class="col-posts">
<span class="post-count">{{ user.postCount }}</span>
<div class="post-stats">
<div class="stat-item">
<span class="stat-value">{{ user.threadCount }}</span>
<span class="stat-label muted">threads</span>
</div>
<div class="stat-divider">/</div>
<div class="stat-item">
<span class="stat-value">{{ user.postCount }}</span>
<span class="stat-label muted">posts</span>
</div>
</div>
</div>
<div class="col-roles">
@@ -139,9 +149,10 @@ import { t } from '@nextcloud/l10n'
import type { Role } from '@/types'
interface AdminUser {
id: number
userId: string
displayName: string
postCount: number
threadCount: number
createdAt: number
updatedAt: number
deletedAt: number | null
@@ -389,10 +400,34 @@ export default defineComponent({
}
.col-posts {
.post-count {
font-weight: 600;
font-size: 1rem;
color: var(--color-main-text);
.post-stats {
display: flex;
align-items: center;
gap: 8px;
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
.stat-value {
font-weight: 600;
font-size: 1rem;
color: var(--color-main-text);
}
.stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.stat-divider {
color: var(--color-text-maxcontrast);
font-weight: 300;
}
}
}