mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1671baf2d | |||
| 71ee133ac6 | |||
| 1add8db287 |
@@ -1 +1 @@
|
||||
{".":"0.2.0"}
|
||||
{".":"0.2.1"}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [0.2.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.2.0...v0.2.1) (2025-11-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* thread card hover styles ([1add8db](https://github.com/chenasraf/nextcloud-forum/commit/1add8db28775d2d13d8b2eb9428a90eb99b32ae8))
|
||||
* unread counts for deleted posts ([71ee133](https://github.com/chenasraf/nextcloud-forum/commit/71ee133ac6b59f9005918594f7e668031b8224fa))
|
||||
|
||||
## [0.2.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.7...v0.2.0) (2025-11-17)
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ This app is in early stages of development. While functional, you may encounter
|
||||
|
||||
The forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.
|
||||
]]></description>
|
||||
<version>0.2.0</version>
|
||||
<version>0.2.1</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
|
||||
<namespace>Forum</namespace>
|
||||
|
||||
@@ -367,17 +367,53 @@ class PostController extends OCSController {
|
||||
$post->setUpdatedAt(time());
|
||||
$this->postMapper->update($post);
|
||||
|
||||
// Update thread post count
|
||||
// Update thread post count and lastPostId
|
||||
try {
|
||||
$thread = $this->threadMapper->find($post->getThreadId());
|
||||
$thread->setPostCount(max(0, $thread->getPostCount() - 1));
|
||||
$thread->setUpdatedAt(time());
|
||||
|
||||
// If the deleted post was the last post, update lastPostId to the previous non-deleted post
|
||||
if ($thread->getLastPostId() === $post->getId()) {
|
||||
// Find the latest non-deleted post in this thread (excluding the one being deleted)
|
||||
$latestPost = $this->postMapper->findLatestByThreadId($thread->getId(), $post->getId());
|
||||
if ($latestPost) {
|
||||
$thread->setLastPostId($latestPost->getId());
|
||||
} else {
|
||||
// No other posts in thread, set to null (or keep first post ID)
|
||||
$thread->setLastPostId(null);
|
||||
}
|
||||
}
|
||||
|
||||
$this->threadMapper->update($thread);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update thread post count after post deletion: ' . $e->getMessage());
|
||||
$this->logger->warning('Failed to update thread after post deletion: ' . $e->getMessage());
|
||||
// Don't fail the request if thread update fails
|
||||
}
|
||||
|
||||
// Update user stats - decrement post count, and thread count if it's the first post
|
||||
try {
|
||||
$this->userStatsMapper->decrementPostCount($post->getAuthorId());
|
||||
|
||||
// If this is the first post of a thread, also decrement thread count
|
||||
if ($post->getIsFirstPost()) {
|
||||
$this->userStatsMapper->decrementThreadCount($post->getAuthorId());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update user stats after post deletion: ' . $e->getMessage());
|
||||
// Don't fail the request if stats update fails
|
||||
}
|
||||
|
||||
// Update category post count
|
||||
try {
|
||||
$category = $this->categoryMapper->find($categoryId);
|
||||
$category->setPostCount(max(0, $category->getPostCount() - 1));
|
||||
$this->categoryMapper->update($category);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update category post count after post deletion: ' . $e->getMessage());
|
||||
// Don't fail the request if category update fails
|
||||
}
|
||||
|
||||
return new DataResponse(['success' => true]);
|
||||
} catch (DoesNotExistException $e) {
|
||||
return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND);
|
||||
|
||||
@@ -396,6 +396,18 @@ class ThreadController extends OCSController {
|
||||
// Don't fail the request if category update fails
|
||||
}
|
||||
|
||||
// Update author's user stats (decrement thread count and all posts in this thread)
|
||||
try {
|
||||
$this->userStatsMapper->decrementThreadCount($thread->getAuthorId());
|
||||
// Decrement post count by the number of posts in this thread
|
||||
if ($thread->getPostCount() > 0) {
|
||||
$this->userStatsMapper->decrementPostCount($thread->getAuthorId(), $thread->getPostCount());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning('Failed to update user stats after thread deletion: ' . $e->getMessage());
|
||||
// Don't fail the request if stats update fails
|
||||
}
|
||||
|
||||
return new DataResponse([
|
||||
'success' => true,
|
||||
'categorySlug' => $categorySlug,
|
||||
|
||||
@@ -87,22 +87,26 @@ class PostMapper extends QBMapper {
|
||||
public function findByAuthorId(string $authorId, int $limit = 50, int $offset = 0, bool $excludeFirstPosts = false): array {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
$qb->select('p.*')
|
||||
->from($this->getTableName(), 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where(
|
||||
$qb->expr()->eq('author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))
|
||||
$qb->expr()->eq('p.author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
$qb->expr()->isNull('p.deleted_at')
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('t.deleted_at')
|
||||
);
|
||||
|
||||
if ($excludeFirstPosts) {
|
||||
$qb->andWhere(
|
||||
$qb->expr()->eq('is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
|
||||
$qb->expr()->eq('p.is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
|
||||
);
|
||||
}
|
||||
|
||||
$qb->orderBy('created_at', 'DESC')
|
||||
$qb->orderBy('p.created_at', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
return $this->findEntities($qb);
|
||||
@@ -114,12 +118,16 @@ class PostMapper extends QBMapper {
|
||||
public function findAll(): array {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
$qb->select('p.*')
|
||||
->from($this->getTableName(), 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
$qb->expr()->isNull('p.deleted_at')
|
||||
)
|
||||
->orderBy('created_at', 'DESC');
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('t.deleted_at')
|
||||
)
|
||||
->orderBy('p.created_at', 'DESC');
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
@@ -129,9 +137,13 @@ class PostMapper extends QBMapper {
|
||||
public function countAll(): int {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select($qb->func()->count('*', 'count'))
|
||||
->from($this->getTableName())
|
||||
->from($this->getTableName(), 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
$qb->expr()->isNull('p.deleted_at')
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('t.deleted_at')
|
||||
);
|
||||
$result = $qb->executeQuery();
|
||||
$row = $result->fetch();
|
||||
@@ -145,10 +157,14 @@ class PostMapper extends QBMapper {
|
||||
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)))
|
||||
->from($this->getTableName(), 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where($qb->expr()->gte('p.created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('deleted_at')
|
||||
$qb->expr()->isNull('p.deleted_at')
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->isNull('t.deleted_at')
|
||||
);
|
||||
$result = $qb->executeQuery();
|
||||
$row = $result->fetch();
|
||||
@@ -156,6 +172,34 @@ class PostMapper extends QBMapper {
|
||||
return (int)($row['count'] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the latest non-deleted post in a thread, excluding a specific post ID
|
||||
*
|
||||
* @param int $threadId Thread ID
|
||||
* @param int|null $excludePostId Post ID to exclude (typically the one being deleted)
|
||||
* @return Post|null Latest post or null if no posts found
|
||||
*/
|
||||
public function findLatestByThreadId(int $threadId, ?int $excludePostId = null): ?Post {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->isNull('deleted_at'));
|
||||
|
||||
if ($excludePostId !== null) {
|
||||
$qb->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($excludePostId, IQueryBuilder::PARAM_INT)));
|
||||
}
|
||||
|
||||
$qb->orderBy('created_at', 'DESC')
|
||||
->setMaxResults(1);
|
||||
|
||||
try {
|
||||
return $this->findEntity($qb);
|
||||
} catch (DoesNotExistException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unread posts in a thread after a specific post ID
|
||||
*
|
||||
|
||||
@@ -56,35 +56,8 @@ class UserStatsService {
|
||||
* @return array{users: int, updated: int, created: int} Statistics about the rebuild
|
||||
*/
|
||||
public function rebuildAllUserStats(): array {
|
||||
// Get all users who have posted or created threads
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->selectDistinct('author_id')
|
||||
->from('forum_posts')
|
||||
->where($qb->expr()->isNotNull('author_id'));
|
||||
$result = $qb->executeQuery();
|
||||
$users = [];
|
||||
while ($row = $result->fetch()) {
|
||||
$users[] = $row['author_id'];
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
$updated = 0;
|
||||
$created = 0;
|
||||
|
||||
foreach ($users as $userId) {
|
||||
$wasCreated = $this->rebuildUserStats($userId);
|
||||
if ($wasCreated) {
|
||||
$created++;
|
||||
} else {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'users' => count($users),
|
||||
'updated' => $updated,
|
||||
'created' => $created,
|
||||
];
|
||||
// Delegate to createStatsForAllUsers which processes all Nextcloud users
|
||||
return $this->createStatsForAllUsers();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,30 +67,37 @@ class UserStatsService {
|
||||
* @return bool True if stats were created, false if they were updated
|
||||
*/
|
||||
public function rebuildUserStats(string $userId): bool {
|
||||
// Count threads created by this user
|
||||
// Count non-deleted threads created by this user
|
||||
$threadQb = $this->db->getQueryBuilder();
|
||||
$threadQb->select($threadQb->func()->count('*', 'count'))
|
||||
->from('forum_threads')
|
||||
->where($threadQb->expr()->eq('author_id', $threadQb->createNamedParameter($userId)));
|
||||
->where($threadQb->expr()->eq('author_id', $threadQb->createNamedParameter($userId)))
|
||||
->andWhere($threadQb->expr()->isNull('deleted_at'));
|
||||
$threadResult = $threadQb->executeQuery();
|
||||
$threadCount = (int)($threadResult->fetchOne() ?? 0);
|
||||
$threadResult->closeCursor();
|
||||
|
||||
// Count posts created by this user
|
||||
// Count non-deleted posts created by this user (from non-deleted threads)
|
||||
$postQb = $this->db->getQueryBuilder();
|
||||
$postQb->select($postQb->func()->count('*', 'count'))
|
||||
->from('forum_posts')
|
||||
->where($postQb->expr()->eq('author_id', $postQb->createNamedParameter($userId)));
|
||||
->from('forum_posts', 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $postQb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where($postQb->expr()->eq('p.author_id', $postQb->createNamedParameter($userId)))
|
||||
->andWhere($postQb->expr()->isNull('p.deleted_at'))
|
||||
->andWhere($postQb->expr()->isNull('t.deleted_at'));
|
||||
$postResult = $postQb->executeQuery();
|
||||
$postCount = (int)($postResult->fetchOne() ?? 0);
|
||||
$postResult->closeCursor();
|
||||
|
||||
// Get the timestamp of the last post
|
||||
// Get the timestamp of the last non-deleted post (from non-deleted threads)
|
||||
$lastPostQb = $this->db->getQueryBuilder();
|
||||
$lastPostQb->select('created_at')
|
||||
->from('forum_posts')
|
||||
->where($lastPostQb->expr()->eq('author_id', $lastPostQb->createNamedParameter($userId)))
|
||||
->orderBy('created_at', 'DESC')
|
||||
$lastPostQb->select('p.created_at')
|
||||
->from('forum_posts', 'p')
|
||||
->innerJoin('p', 'forum_threads', 't', $lastPostQb->expr()->eq('p.thread_id', 't.id'))
|
||||
->where($lastPostQb->expr()->eq('p.author_id', $lastPostQb->createNamedParameter($userId)))
|
||||
->andWhere($lastPostQb->expr()->isNull('p.deleted_at'))
|
||||
->andWhere($lastPostQb->expr()->isNull('t.deleted_at'))
|
||||
->orderBy('p.created_at', 'DESC')
|
||||
->setMaxResults(1);
|
||||
$lastPostResult = $lastPostQb->executeQuery();
|
||||
$lastPostAt = $lastPostResult->fetchOne();
|
||||
|
||||
@@ -111,10 +111,11 @@ export default defineComponent({
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover,
|
||||
&.unread:hover,
|
||||
&.pinned:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.pinned {
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.2.0
|
||||
0.2.1
|
||||
|
||||
Reference in New Issue
Block a user