mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: user listener + user soft delete
This commit is contained in:
@@ -4,10 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Forum\AppInfo;
|
||||
|
||||
use OCA\Forum\Listener\UserEventListener;
|
||||
use OCP\AppFramework\App;
|
||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
use OCP\User\Events\UserChangedEvent;
|
||||
use OCP\User\Events\UserCreatedEvent;
|
||||
use OCP\User\Events\UserDeletedEvent;
|
||||
|
||||
class Application extends App implements IBootstrap {
|
||||
public const APP_ID = 'forum';
|
||||
@@ -21,6 +25,10 @@ class Application extends App implements IBootstrap {
|
||||
}
|
||||
|
||||
public function register(IRegistrationContext $context): void {
|
||||
// Register user event listeners for syncing forum users with Nextcloud users
|
||||
$context->registerEventListener(UserCreatedEvent::class, UserEventListener::class);
|
||||
$context->registerEventListener(UserDeletedEvent::class, UserEventListener::class);
|
||||
$context->registerEventListener(UserChangedEvent::class, UserEventListener::class);
|
||||
}
|
||||
|
||||
public function boot(IBootContext $context): void {
|
||||
|
||||
@@ -7,6 +7,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Forum\Controller;
|
||||
|
||||
use OCA\Forum\Db\Thread;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
@@ -41,7 +42,7 @@ class ThreadController extends OCSController {
|
||||
public function index(): DataResponse {
|
||||
try {
|
||||
$threads = $this->threadMapper->findAll();
|
||||
return new DataResponse(array_map(fn ($t) => $t->jsonSerialize(), $threads));
|
||||
return new DataResponse(array_map(fn ($t) => Thread::enrichThreadAuthor($t), $threads));
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching threads: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
@@ -63,7 +64,7 @@ class ThreadController extends OCSController {
|
||||
public function byCategory(int $categoryId, int $limit = 50, int $offset = 0): DataResponse {
|
||||
try {
|
||||
$threads = $this->threadMapper->findByCategoryId($categoryId, $limit, $offset);
|
||||
return new DataResponse(array_map(fn ($t) => $t->jsonSerialize(), $threads));
|
||||
return new DataResponse(array_map(fn ($t) => Thread::enrichThreadAuthor($t), $threads));
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching threads by category: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to fetch threads'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
@@ -89,7 +90,7 @@ class ThreadController extends OCSController {
|
||||
/** @var \OCA\Forum\Db\Thread */
|
||||
$thread = $this->threadMapper->update($thread);
|
||||
|
||||
return new DataResponse($thread->jsonSerialize());
|
||||
return new DataResponse(Thread::enrichThreadAuthor($thread));
|
||||
} catch (DoesNotExistException $e) {
|
||||
return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND);
|
||||
} catch (\Exception $e) {
|
||||
@@ -117,7 +118,7 @@ class ThreadController extends OCSController {
|
||||
/** @var \OCA\Forum\Db\Thread */
|
||||
$thread = $this->threadMapper->update($thread);
|
||||
|
||||
return new DataResponse($thread->jsonSerialize());
|
||||
return new DataResponse(Thread::enrichThreadAuthor($thread));
|
||||
} catch (DoesNotExistException $e) {
|
||||
return new DataResponse(['error' => 'Thread not found'], Http::STATUS_NOT_FOUND);
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -22,12 +22,15 @@ use OCP\AppFramework\Db\Entity;
|
||||
* @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');
|
||||
@@ -35,6 +38,7 @@ class ForumUser extends Entity implements JsonSerializable {
|
||||
$this->addType('postCount', 'integer');
|
||||
$this->addType('createdAt', 'integer');
|
||||
$this->addType('updatedAt', 'integer');
|
||||
$this->addType('deletedAt', 'integer');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
@@ -44,6 +48,23 @@ class ForumUser extends Entity implements JsonSerializable {
|
||||
'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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,12 +72,27 @@ class Post extends Entity implements JsonSerializable {
|
||||
if (!is_array($post)) {
|
||||
$post = $post->jsonSerialize();
|
||||
}
|
||||
|
||||
// Parse BBCode content
|
||||
$service = \OC::$server->get(\OCA\Forum\Service\BBCodeService::class);
|
||||
if (empty($bbcodes)) {
|
||||
$mapper = \OC::$server->get(\OCA\Forum\Db\BBCodeMapper::class);
|
||||
$bbcodes = $mapper->findAllEnabled();
|
||||
}
|
||||
$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;
|
||||
}
|
||||
|
||||
return $post;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,4 +86,24 @@ class Thread extends Entity implements JsonSerializable {
|
||||
'updatedAt' => $this->getUpdatedAt(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function enrichThreadAuthor(mixed $thread): array {
|
||||
if (!is_array($thread)) {
|
||||
$thread = $thread->jsonSerialize();
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
return $thread;
|
||||
}
|
||||
}
|
||||
|
||||
125
lib/Listener/UserEventListener.php
Normal file
125
lib/Listener/UserEventListener.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\Forum\Listener;
|
||||
|
||||
use OCA\Forum\Db\ForumUser;
|
||||
use OCA\Forum\Db\ForumUserMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
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>
|
||||
*/
|
||||
class UserEventListener implements IEventListener {
|
||||
public function __construct(
|
||||
private ForumUserMapper $forumUserMapper,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof UserCreatedEvent) {
|
||||
$this->handleUserCreated($event);
|
||||
} elseif ($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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function handleUserDeleted(UserDeletedEvent $event): void {
|
||||
$user = $event->getUser();
|
||||
$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}");
|
||||
} 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,11 @@ 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');
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
<div class="post-card" :class="{ 'first-post': isFirstPost }">
|
||||
<div class="post-header">
|
||||
<div class="author-info">
|
||||
<NcAvatar :user="post.authorId" :size="32" />
|
||||
<NcAvatar v-if="!post.authorIsDeleted" :user="post.authorId" :size="32" />
|
||||
<NcAvatar v-else :display-name="post.authorDisplayName" :size="32" />
|
||||
<div class="author-details">
|
||||
<span class="author-name">{{ post.authorId }}</span>
|
||||
<span class="author-name" :class="{ 'deleted-user': post.authorIsDeleted }">
|
||||
{{ post.authorDisplayName || post.authorId }}
|
||||
</span>
|
||||
<div class="post-meta">
|
||||
<NcDateTime v-if="post.createdAt" :timestamp="post.createdAt * 1000" />
|
||||
<span v-if="post.isEdited" class="edited-badge">
|
||||
@@ -156,6 +159,11 @@ export default {
|
||||
font-weight: 600;
|
||||
color: var(--color-main-text);
|
||||
font-size: 1rem;
|
||||
|
||||
&.deleted-user {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
<div class="thread-meta">
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">{{ strings.by }}</span>
|
||||
<span class="meta-value">{{ thread.authorId }}</span>
|
||||
<span class="meta-value" :class="{ 'deleted-user': thread.authorIsDeleted }">
|
||||
{{ thread.authorDisplayName || thread.authorId }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="meta-divider">·</span>
|
||||
<span class="meta-item">
|
||||
@@ -179,6 +181,11 @@ export default {
|
||||
.meta-value {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-lighter);
|
||||
|
||||
&.deleted-user {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-divider {
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
<div class="thread-meta">
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">{{ strings.by }}</span>
|
||||
<span class="meta-value">{{ thread.authorId }}</span>
|
||||
<span class="meta-value" :class="{ 'deleted-user': thread.authorIsDeleted }">
|
||||
{{ thread.authorDisplayName || thread.authorId }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="meta-divider">·</span>
|
||||
<span class="meta-item">
|
||||
@@ -360,6 +362,11 @@ export default {
|
||||
.meta-value {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-lighter);
|
||||
|
||||
&.deleted-user {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-divider {
|
||||
|
||||
Reference in New Issue
Block a user