diff --git a/lib/Controller/UserPreferencesController.php b/lib/Controller/UserPreferencesController.php index 7f935fa..ac78e3e 100644 --- a/lib/Controller/UserPreferencesController.php +++ b/lib/Controller/UserPreferencesController.php @@ -56,7 +56,8 @@ class UserPreferencesController extends OCSController { /** * Update user preferences * - * @param array $preferences Key-value pairs of preferences to update + * Request body should contain key-value pairs of preferences to update + * * @return DataResponse, array{}>|DataResponse|DataResponse * * 200: Preferences updated @@ -65,13 +66,18 @@ class UserPreferencesController extends OCSController { */ #[NoAdminRequired] #[ApiRoute(verb: 'PUT', url: '/api/user-preferences')] - public function update(array $preferences): DataResponse { + public function update(): DataResponse { try { $user = $this->userSession->getUser(); if (!$user) { return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED); } + // Get preferences directly from request body + $preferences = $this->request->getParams(); + // Remove route-specific params that Nextcloud adds + unset($preferences['_route']); + $allPreferences = $this->preferencesService->updatePreferences($user->getUID(), $preferences); return new DataResponse($allPreferences); } catch (\InvalidArgumentException $e) { diff --git a/lib/Db/UserStats.php b/lib/Db/UserStats.php index bb2bde4..22fb4b1 100644 --- a/lib/Db/UserStats.php +++ b/lib/Db/UserStats.php @@ -23,6 +23,8 @@ use OCP\AppFramework\Db\Entity; * @method void setLastPostAt(?int $lastPostAt) * @method int|null getDeletedAt() * @method void setDeletedAt(?int $deletedAt) + * @method string|null getSignature() + * @method void setSignature(?string $signature) * @method int getCreatedAt() * @method void setCreatedAt(int $createdAt) * @method int getUpdatedAt() @@ -35,6 +37,7 @@ class UserStats extends Entity implements JsonSerializable { protected int $threadCount = 0; protected ?int $lastPostAt = null; protected ?int $deletedAt = null; + protected ?string $signature = null; protected int $createdAt = 0; protected int $updatedAt = 0; @@ -45,6 +48,7 @@ class UserStats extends Entity implements JsonSerializable { $this->addType('threadCount', 'integer'); $this->addType('lastPostAt', 'integer'); $this->addType('deletedAt', 'integer'); + $this->addType('signature', 'string'); $this->addType('createdAt', 'integer'); $this->addType('updatedAt', 'integer'); } @@ -57,6 +61,7 @@ class UserStats extends Entity implements JsonSerializable { 'lastPostAt' => $this->lastPostAt, 'deletedAt' => $this->deletedAt, 'isDeleted' => $this->deletedAt !== null, + 'signature' => $this->signature, 'createdAt' => $this->createdAt, 'updatedAt' => $this->updatedAt, ]; diff --git a/lib/Db/UserStatsMapper.php b/lib/Db/UserStatsMapper.php index 3c73ac2..a9befe9 100644 --- a/lib/Db/UserStatsMapper.php +++ b/lib/Db/UserStatsMapper.php @@ -52,6 +52,24 @@ class UserStatsMapper extends QBMapper { return $this->findEntities($qb); } + /** + * Find user stats by multiple user IDs + * + * @param array $userIds + * @return array + */ + public function findByUserIds(array $userIds): array { + if (empty($userIds)) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->in('user_id', $qb->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY))); + return $this->findEntities($qb); + } + /** * Create or update user stats (upsert pattern) * This is used when we need to ensure stats exist for a user diff --git a/lib/Migration/Version8Date20251128000000.php b/lib/Migration/Version8Date20251128000000.php index 339608d..8cc6be1 100644 --- a/lib/Migration/Version8Date20251128000000.php +++ b/lib/Migration/Version8Date20251128000000.php @@ -13,10 +13,9 @@ use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; /** - * Remove slug column from forum_posts table - * - * Post slugs were never used and are unnecessary - posts are always - * accessed by ID within the context of a thread. + * Version 8 Migration: + * - Remove slug column from forum_posts table (never used) + * - Add signature column to forum_user_stats table */ class Version8Date20251128000000 extends SimpleMigrationStep { /** @@ -44,6 +43,18 @@ class Version8Date20251128000000 extends SimpleMigrationStep { } } + // Add signature column to user stats + if ($schema->hasTable('forum_user_stats')) { + $table = $schema->getTable('forum_user_stats'); + + if (!$table->hasColumn('signature')) { + $table->addColumn('signature', 'text', [ + 'notnull' => false, + 'default' => null, + ]); + } + } + return $schema; } } diff --git a/lib/Service/UserPreferencesService.php b/lib/Service/UserPreferencesService.php index baccf5f..12362fe 100644 --- a/lib/Service/UserPreferencesService.php +++ b/lib/Service/UserPreferencesService.php @@ -8,6 +8,8 @@ declare(strict_types=1); namespace OCA\Forum\Service; use OCA\Forum\AppInfo\Application; +use OCA\Forum\Db\UserStatsMapper; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\IConfig; use Psr\Log\LoggerInterface; @@ -18,20 +20,31 @@ class UserPreferencesService { /** Preference key for upload directory path */ public const PREF_UPLOAD_DIRECTORY = 'upload_directory'; + /** Preference key for user signature (stored in user_stats table) */ + public const PREF_SIGNATURE = 'signature'; + /** @var array Default preference values */ private const DEFAULTS = [ self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS => true, self::PREF_UPLOAD_DIRECTORY => 'Forum', + self::PREF_SIGNATURE => '', ]; /** @var array List of valid preference keys */ private const VALID_KEYS = [ self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS, self::PREF_UPLOAD_DIRECTORY, + self::PREF_SIGNATURE, + ]; + + /** @var array Keys stored in user_stats table instead of config */ + private const USER_STATS_KEYS = [ + self::PREF_SIGNATURE, ]; public function __construct( private IConfig $config, + private UserStatsMapper $userStatsMapper, private LoggerInterface $logger, ) { } @@ -65,6 +78,11 @@ class UserPreferencesService { throw new \InvalidArgumentException("Invalid preference key: $key"); } + // Handle keys stored in user_stats table + if (in_array($key, self::USER_STATS_KEYS, true)) { + return $this->getUserStatsValue($userId, $key); + } + $default = self::DEFAULTS[$key] ?? null; $value = $this->config->getUserValue($userId, Application::APP_ID, $key, $default); @@ -109,6 +127,12 @@ class UserPreferencesService { throw new \InvalidArgumentException("Invalid preference key: $key"); } + // Handle keys stored in user_stats table + if (in_array($key, self::USER_STATS_KEYS, true)) { + $this->setUserStatsValue($userId, $key, $value); + return; + } + $stringValue = $this->stringifyValue($value); $this->config->setUserValue($userId, Application::APP_ID, $key, $stringValue); } @@ -144,4 +168,42 @@ class UserPreferencesService { } return (string)$value; } + + /** + * Get a value from user_stats table + * + * @param string $userId The user ID + * @param string $key The preference key + * @return mixed The value + */ + private function getUserStatsValue(string $userId, string $key): mixed { + try { + $stats = $this->userStatsMapper->find($userId); + return match ($key) { + self::PREF_SIGNATURE => $stats->getSignature() ?? '', + default => self::DEFAULTS[$key] ?? null, + }; + } catch (DoesNotExistException $e) { + return self::DEFAULTS[$key] ?? null; + } + } + + /** + * Set a value in user_stats table + * + * @param string $userId The user ID + * @param string $key The preference key + * @param mixed $value The value to set + */ + private function setUserStatsValue(string $userId, string $key, mixed $value): void { + $stats = $this->userStatsMapper->createOrUpdate($userId); + + match ($key) { + self::PREF_SIGNATURE => $stats->setSignature((string)$value), + default => null, + }; + + $stats->setUpdatedAt(time()); + $this->userStatsMapper->update($stats); + } } diff --git a/lib/Service/UserService.php b/lib/Service/UserService.php index be758b2..5311449 100644 --- a/lib/Service/UserService.php +++ b/lib/Service/UserService.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace OCA\Forum\Service; +use OCA\Forum\Db\BBCodeMapper; use OCA\Forum\Db\RoleMapper; use OCA\Forum\Db\UserRoleMapper; use OCA\Forum\Db\UserStatsMapper; @@ -24,6 +25,8 @@ class UserService { private UserStatsMapper $userStatsMapper, private RoleMapper $roleMapper, private UserRoleMapper $userRoleMapper, + private BBCodeMapper $bbCodeMapper, + private BBCodeService $bbCodeService, private IL10N $l10n, ) { } @@ -64,13 +67,14 @@ class UserService { } /** - * Enrich user data with display name, deleted status, and roles + * Enrich user data with display name, deleted status, roles, and signature * * @param string $userId * @param array|null $roles Optional pre-fetched roles array - * @return array{userId: string, displayName: string, isDeleted: bool, roles: array} + * @param array|null $bbcodes Optional pre-fetched BBCode definitions for parsing signatures + * @return array{userId: string, displayName: string, isDeleted: bool, roles: array, signature: ?string, signatureRaw: ?string} */ - public function enrichUserData(string $userId, ?array $roles = null): array { + public function enrichUserData(string $userId, ?array $roles = null, ?array $bbcodes = null): array { $isDeleted = $this->isUserDeleted($userId); $displayName = $this->getUserDisplayName($userId); @@ -88,11 +92,30 @@ class UserService { } } + // Get signature from user stats + $signatureRaw = null; + $signature = null; + try { + $stats = $this->userStatsMapper->find($userId); + $signatureRaw = $stats->getSignature(); + if ($signatureRaw !== null && $signatureRaw !== '') { + // Parse BBCode in signature + if ($bbcodes === null) { + $bbcodes = $this->bbCodeMapper->findAllEnabled(); + } + $signature = $this->bbCodeService->parse($signatureRaw, $bbcodes); + } + } catch (DoesNotExistException $e) { + // No stats record, no signature + } + return [ 'userId' => $userId, 'displayName' => $displayName, 'isDeleted' => $isDeleted, 'roles' => $roles, + 'signature' => $signature, + 'signatureRaw' => $signatureRaw, ]; } @@ -101,9 +124,10 @@ class UserService { * * @param array $userIds * @param array $rolesMap Optional pre-fetched roles map (userId => roles[]) - * @return array + * @param array|null $bbcodes Optional pre-fetched BBCode definitions for parsing signatures + * @return array */ - public function enrichMultipleUsers(array $userIds, ?array $rolesMap = null): array { + public function enrichMultipleUsers(array $userIds, ?array $rolesMap = null, ?array $bbcodes = null): array { $result = []; // If roles not provided, fetch them all at once @@ -111,15 +135,31 @@ class UserService { $rolesMap = $this->fetchRolesForUsers($userIds); } + // Fetch all user stats at once for signatures + $signaturesMap = $this->fetchSignaturesForUsers($userIds); + + // Fetch BBCodes once for parsing all signatures (if not provided) + if ($bbcodes === null) { + $bbcodes = $this->bbCodeMapper->findAllEnabled(); + } + foreach ($userIds as $userId) { $isDeleted = $this->isUserDeleted($userId); $displayName = $this->getUserDisplayName($userId); + $signatureRaw = $signaturesMap[$userId] ?? null; + $signature = null; + if ($signatureRaw !== null && $signatureRaw !== '') { + $signature = $this->bbCodeService->parse($signatureRaw, $bbcodes); + } + $result[$userId] = [ 'userId' => $userId, 'displayName' => $displayName, 'isDeleted' => $isDeleted, 'roles' => $rolesMap[$userId] ?? [], + 'signature' => $signature, + 'signatureRaw' => $signatureRaw, ]; } return $result; @@ -178,4 +218,33 @@ class UserService { return $rolesMap; } + + /** + * Fetch signatures for multiple users efficiently + * + * @param array $userIds + * @return array Map of userId => signature (raw) + */ + private function fetchSignaturesForUsers(array $userIds): array { + if (empty($userIds)) { + return []; + } + + $signaturesMap = []; + + // Initialize all user IDs with null + foreach ($userIds as $userId) { + $signaturesMap[$userId] = null; + } + + // Fetch all user stats for these users + $userStats = $this->userStatsMapper->findByUserIds($userIds); + + // Extract signatures + foreach ($userStats as $stats) { + $signaturesMap[$stats->getUserId()] = $stats->getSignature(); + } + + return $signaturesMap; + } } diff --git a/openapi.json b/openapi.json index fe7d446..920d6e4 100644 --- a/openapi.json +++ b/openapi.json @@ -8283,6 +8283,7 @@ "put": { "operationId": "user_preferences-update", "summary": "Update user preferences", + "description": "Request body should contain key-value pairs of preferences to update", "tags": [ "user_preferences" ], @@ -8294,28 +8295,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "preferences" - ], - "properties": { - "preferences": { - "type": "object", - "description": "Key-value pairs of preferences to update", - "additionalProperties": { - "type": "object" - } - } - } - } - } - } - }, "parameters": [ { "name": "OCS-APIRequest", diff --git a/src/components/PostCard.vue b/src/components/PostCard.vue index 40db152..dff69a5 100644 --- a/src/components/PostCard.vue +++ b/src/components/PostCard.vue @@ -59,6 +59,11 @@
+ +
+
+
+ + +
+

{{ strings.signatureTitle }}

+

{{ strings.signatureDesc }}

+ +
+ + +

{{ strings.signatureHint }}

+
+
+
@@ -107,6 +124,7 @@ import NcTextField from '@nextcloud/vue/components/NcTextField' import AppToolbar from '@/components/AppToolbar.vue' import PageWrapper from '@/components/PageWrapper.vue' import PageHeader from '@/components/PageHeader.vue' +import BBCodeEditor from '@/components/BBCodeEditor.vue' import ArrowLeftIcon from '@icons/ArrowLeft.vue' import CheckIcon from '@icons/Check.vue' import FolderIcon from '@icons/Folder.vue' @@ -117,6 +135,7 @@ import { getFilePickerBuilder, FilePickerType } from '@nextcloud/dialogs' interface UserPreferences { auto_subscribe_created_threads: boolean upload_directory: string + signature: string } export default defineComponent({ @@ -130,6 +149,7 @@ export default defineComponent({ AppToolbar, PageWrapper, PageHeader, + BBCodeEditor, ArrowLeftIcon, CheckIcon, FolderIcon, @@ -143,10 +163,12 @@ export default defineComponent({ originalData: { auto_subscribe_created_threads: true, upload_directory: 'Forum', + signature: '', } as UserPreferences, formData: { auto_subscribe_created_threads: true, upload_directory: 'Forum', + signature: '', } as UserPreferences, strings: { @@ -174,6 +196,11 @@ export default defineComponent({ save: t('forum', 'Save'), cancel: t('forum', 'Cancel'), saveSuccess: t('forum', 'Preferences saved'), + signatureTitle: t('forum', 'Signature'), + signatureDesc: t('forum', 'Your signature appears at the bottom of your posts'), + signatureLabel: t('forum', 'Signature'), + signatureHint: t('forum', 'You can use BBCode formatting in your signature'), + signaturePlaceholder: t('forum', 'Enter your signature …'), }, } }, @@ -182,7 +209,8 @@ export default defineComponent({ return ( this.formData.auto_subscribe_created_threads !== this.originalData.auto_subscribe_created_threads || - this.formData.upload_directory !== this.originalData.upload_directory + this.formData.upload_directory !== this.originalData.upload_directory || + this.formData.signature !== this.originalData.signature ) }, }, diff --git a/tests/unit/Service/UserPreferencesServiceTest.php b/tests/unit/Service/UserPreferencesServiceTest.php index ee42339..8fe011f 100644 --- a/tests/unit/Service/UserPreferencesServiceTest.php +++ b/tests/unit/Service/UserPreferencesServiceTest.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace OCA\Forum\Tests\Service; use OCA\Forum\AppInfo\Application; +use OCA\Forum\Db\UserStatsMapper; use OCA\Forum\Service\UserPreferencesService; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\IConfig; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -13,14 +15,21 @@ use Psr\Log\LoggerInterface; class UserPreferencesServiceTest extends TestCase { private UserPreferencesService $service; private IConfig $config; + private UserStatsMapper $userStatsMapper; private LoggerInterface $logger; protected function setUp(): void { $this->config = $this->createMock(IConfig::class); + $this->userStatsMapper = $this->createMock(UserStatsMapper::class); $this->logger = $this->createMock(LoggerInterface::class); + // By default, mock no user stats (no signature) + $this->userStatsMapper->method('find') + ->willThrowException(new DoesNotExistException('')); + $this->service = new UserPreferencesService( $this->config, + $this->userStatsMapper, $this->logger ); } @@ -28,6 +37,7 @@ class UserPreferencesServiceTest extends TestCase { public function testGetAllPreferencesReturnsAllPreferences(): void { $userId = 'user1'; + // Only config-based preferences (signature is from user_stats) $this->config->expects($this->exactly(2)) ->method('getUserValue') ->willReturnCallback(function ($uid, $appId, $key, $default) use ($userId) { @@ -44,9 +54,10 @@ class UserPreferencesServiceTest extends TestCase { $result = $this->service->getAllPreferences($userId); $this->assertIsArray($result); - $this->assertCount(2, $result); + $this->assertCount(3, $result); $this->assertTrue($result[UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS]); $this->assertEquals('Forum', $result[UserPreferencesService::PREF_UPLOAD_DIRECTORY]); + $this->assertEquals('', $result[UserPreferencesService::PREF_SIGNATURE]); } public function testGetPreferenceReturnsCorrectValue(): void { @@ -145,8 +156,10 @@ class UserPreferencesServiceTest extends TestCase { $result = $this->service->updatePreferences($userId, $preferences); $this->assertIsArray($result); + $this->assertCount(3, $result); $this->assertFalse($result[UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS]); $this->assertEquals('Documents', $result[UserPreferencesService::PREF_UPLOAD_DIRECTORY]); + $this->assertEquals('', $result[UserPreferencesService::PREF_SIGNATURE]); } public function testUpdatePreferencesThrowsExceptionForInvalidKey(): void {