feat: user listener + user soft delete

This commit is contained in:
2025-11-07 02:40:41 +02:00
parent 5006014475
commit bbe4a1b647
10 changed files with 225 additions and 8 deletions

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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());
}
}
}

View File

@@ -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');
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {