mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
feat: remove forum_users table
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
198
lib/Db/UserStatsMapper.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
89
lib/Service/UserService.php
Normal file
89
lib/Service/UserService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
276
openapi.json
276
openapi.json
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user