feat: user signatures

This commit is contained in:
2025-11-29 18:51:41 +02:00
parent 084402c7bc
commit 0f6988b71c
11 changed files with 244 additions and 36 deletions

View File

@@ -56,7 +56,8 @@ class UserPreferencesController extends OCSController {
/**
* Update user preferences
*
* @param array<string, mixed> $preferences Key-value pairs of preferences to update
* Request body should contain key-value pairs of preferences to update
*
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{error: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 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) {

View File

@@ -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,
];

View File

@@ -52,6 +52,24 @@ class UserStatsMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Find user stats by multiple user IDs
*
* @param array<string> $userIds
* @return array<UserStats>
*/
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

View File

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

View File

@@ -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<string, mixed> Default preference values */
private const DEFAULTS = [
self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS => true,
self::PREF_UPLOAD_DIRECTORY => 'Forum',
self::PREF_SIGNATURE => '',
];
/** @var array<string> List of valid preference keys */
private const VALID_KEYS = [
self::PREF_AUTO_SUBSCRIBE_CREATED_THREADS,
self::PREF_UPLOAD_DIRECTORY,
self::PREF_SIGNATURE,
];
/** @var array<string> 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);
}
}

View File

@@ -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<string> $userIds
* @param array<string, array> $rolesMap Optional pre-fetched roles map (userId => roles[])
* @return array<string, array{userId: string, displayName: string, isDeleted: bool, roles: array}>
* @param array|null $bbcodes Optional pre-fetched BBCode definitions for parsing signatures
* @return array<string, array{userId: string, displayName: string, isDeleted: bool, roles: array, signature: ?string, signatureRaw: ?string}>
*/
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<string> $userIds
* @return array<string, ?string> 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;
}
}

View File

@@ -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",

View File

@@ -59,6 +59,11 @@
<div v-else class="content-text" v-html="formattedContent"></div>
</div>
<!-- Signature -->
<div v-if="hasSignature && !isEditing" class="post-signature">
<div class="signature-content" v-html="post.author?.signature"></div>
</div>
<!-- Reactions (hidden when editing) -->
<PostReactions
v-if="!isEditing"
@@ -179,6 +184,9 @@ export default defineComponent({
// BBCodeService handles HTML escaping before parsing BBCodes
return this.post.content
},
hasSignature(): boolean {
return !!this.post.author?.signature
},
},
methods: {
closeActionsMenu() {
@@ -339,7 +347,14 @@ export default defineComponent({
margin-top: 12px;
}
.content-text {
.post-signature {
margin-top: 24px;
padding-top: 24px;
border-top: 1px dashed var(--color-border-dark);
}
.content-text,
.signature-content {
color: var(--color-main-text);
line-height: 1.6;
font-size: 0.95rem;

View File

@@ -30,6 +30,8 @@ export interface User {
displayName: string
isDeleted: boolean
roles: Role[]
signature: string | null
signatureRaw: string | null
}
export interface Thread {

View File

@@ -73,6 +73,23 @@
</div>
</div>
<!-- Signature Section -->
<div class="form-section">
<h3>{{ strings.signatureTitle }}</h3>
<p class="section-description muted">{{ strings.signatureDesc }}</p>
<div class="preference-item">
<label class="preference-label">{{ strings.signatureLabel }}</label>
<BBCodeEditor
v-model="formData.signature"
:placeholder="strings.signaturePlaceholder"
:rows="3"
min-height="5rem"
/>
<p class="preference-hint">{{ strings.signatureHint }}</p>
</div>
</div>
<!-- Actions -->
<div class="form-actions">
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
@@ -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
)
},
},

View File

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