diff --git a/.github/workflows/phpunit-incremental.yml b/.github/workflows/phpunit-incremental.yml index fa92a45..70febe9 100644 --- a/.github/workflows/phpunit-incremental.yml +++ b/.github/workflows/phpunit-incremental.yml @@ -15,9 +15,16 @@ concurrency: cancel-in-progress: true jobs: - phpunit-incremental: + phpunit-incremental-v0-14-0: uses: chenasraf/workflows/.github/workflows/nextcloud-phpunit-incremental.yml@nextcloud-latest secrets: inherit with: baseline-version: 'v0.14.0' validation-query: 'SELECT COUNT(*) FROM oc_forum_users' + + phpunit-incremental-v0-22-8: + uses: chenasraf/workflows/.github/workflows/nextcloud-phpunit-incremental.yml@nextcloud-latest + secrets: inherit + with: + baseline-version: 'v0.22.8' + validation-query: 'SELECT COUNT(*) FROM oc_forum_users' diff --git a/lib/Controller/BookmarkController.php b/lib/Controller/BookmarkController.php index 39ba873..29732ee 100644 --- a/lib/Controller/BookmarkController.php +++ b/lib/Controller/BookmarkController.php @@ -121,7 +121,7 @@ class BookmarkController extends OCSController { * * @param int $page Page number (1-indexed) * @param int $perPage Number of threads per page - * @return DataResponse>, pagination: array{page: int, perPage: int, total: int, totalPages: int}, readMarkers: array}, array{}> + * @return DataResponse>, pagination: array{page: int, perPage: int, total: int, totalPages: int}, readMarkers: array}, array{}> * * 200: Bookmarked threads returned with pagination and read markers */ @@ -197,8 +197,8 @@ class BookmarkController extends OCSController { $readMarkers = []; $markers = $this->readMarkerMapper->findByUserAndThreads($userId, $threadIds); foreach ($markers as $marker) { - $readMarkers[$marker->getThreadId()] = [ - 'threadId' => $marker->getThreadId(), + $readMarkers[$marker->getEntityId()] = [ + 'entityId' => $marker->getEntityId(), 'lastReadPostId' => $marker->getLastReadPostId(), 'readAt' => $marker->getReadAt(), ]; diff --git a/lib/Controller/CategoryController.php b/lib/Controller/CategoryController.php index eaaaafc..326a0dc 100644 --- a/lib/Controller/CategoryController.php +++ b/lib/Controller/CategoryController.php @@ -12,6 +12,7 @@ use OCA\Forum\Db\CategoryMapper; use OCA\Forum\Db\CategoryPerm; use OCA\Forum\Db\CategoryPermMapper; use OCA\Forum\Db\CatHeaderMapper; +use OCA\Forum\Db\ReadMarkerMapper; use OCA\Forum\Db\Role; use OCA\Forum\Db\RoleMapper; use OCA\Forum\Db\ThreadMapper; @@ -35,6 +36,7 @@ class CategoryController extends OCSController { private CategoryMapper $categoryMapper, private CategoryPermMapper $categoryPermMapper, private ThreadMapper $threadMapper, + private ReadMarkerMapper $readMarkerMapper, private RoleMapper $roleMapper, private IUserSession $userSession, private IGroupManager $groupManager, @@ -55,9 +57,20 @@ class CategoryController extends OCSController { #[ApiRoute(verb: 'GET', url: '/api/categories')] public function index(): DataResponse { try { - // Fetch all headers and categories in just 2 queries + // Fetch all headers, categories, and last activity timestamps $headers = $this->catHeaderMapper->findAll(); $allCategories = $this->categoryMapper->findAll(); + $lastActivityMap = $this->threadMapper->getLastActivityByCategories(); + + // Fetch category read markers for authenticated users + $readMarkerMap = []; + $user = $this->userSession->getUser(); + if ($user) { + $markers = $this->readMarkerMapper->findCategoryMarkersByUserId($user->getUID()); + foreach ($markers as $marker) { + $readMarkerMap[$marker->getEntityId()] = $marker->getReadAt(); + } + } // Group categories by header_id $categoriesByHeader = []; @@ -66,7 +79,10 @@ class CategoryController extends OCSController { if (!isset($categoriesByHeader[$headerId])) { $categoriesByHeader[$headerId] = []; } - $categoriesByHeader[$headerId][] = $category->jsonSerialize(); + $categoryData = $category->jsonSerialize(); + $categoryData['lastActivityAt'] = $lastActivityMap[$category->getId()] ?? null; + $categoryData['readAt'] = $readMarkerMap[$category->getId()] ?? null; + $categoriesByHeader[$headerId][] = $categoryData; } // Build result with nested categories diff --git a/lib/Controller/ReadMarkerController.php b/lib/Controller/ReadMarkerController.php index 0749ab6..b112135 100644 --- a/lib/Controller/ReadMarkerController.php +++ b/lib/Controller/ReadMarkerController.php @@ -35,19 +35,34 @@ class ReadMarkerController extends OCSController { * Get read markers for multiple threads * * @param string $threadIds Array of thread IDs (comma-separated in query string) - * @return DataResponse, array{}> + * @param string $markerType Marker type ('thread' or 'category') + * @return DataResponse, array{}> * - * 200: Read markers returned (keyed by thread ID) + * 200: Read markers returned (keyed by entity ID) */ #[NoAdminRequired] #[ApiRoute(verb: 'GET', url: '/api/read-markers')] - public function index(string $threadIds = ''): DataResponse { + public function index(string $threadIds = '', string $markerType = 'thread'): DataResponse { try { $user = $this->userSession->getUser(); if (!$user) { return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED); } + // Category markers + if ($markerType === 'category') { + $markers = $this->readMarkerMapper->findCategoryMarkersByUserId($user->getUID()); + $result = []; + foreach ($markers as $marker) { + $result[$marker->getEntityId()] = [ + 'entityId' => $marker->getEntityId(), + 'readAt' => $marker->getReadAt(), + ]; + } + return new DataResponse($result); + } + + // Thread markers (default) // Parse thread IDs from query parameter $threadIdArray = []; if (!empty($threadIds)) { @@ -59,8 +74,8 @@ class ReadMarkerController extends OCSController { $markers = $this->readMarkerMapper->findByUserId($user->getUID()); $result = []; foreach ($markers as $marker) { - $result[$marker->getThreadId()] = [ - 'threadId' => $marker->getThreadId(), + $result[$marker->getEntityId()] = [ + 'entityId' => $marker->getEntityId(), 'lastReadPostId' => $marker->getLastReadPostId(), 'readAt' => $marker->getReadAt(), ]; @@ -73,8 +88,8 @@ class ReadMarkerController extends OCSController { // Convert to associative array keyed by thread ID for easier frontend lookup $result = []; foreach ($markers as $marker) { - $result[$marker->getThreadId()] = [ - 'threadId' => $marker->getThreadId(), + $result[$marker->getEntityId()] = [ + 'entityId' => $marker->getEntityId(), 'lastReadPostId' => $marker->getLastReadPostId(), 'readAt' => $marker->getReadAt(), ]; @@ -115,23 +130,38 @@ class ReadMarkerController extends OCSController { } /** - * Mark a thread as read + * Mark a thread or category as read * * @param int $threadId Thread ID * @param int $lastReadPostId Last read post ID + * @param int|null $categoryId Category ID (if provided, creates a category marker instead) * @return DataResponse, array{}> * - * 200: Thread marked as read + * 200: Marked as read */ #[NoAdminRequired] #[ApiRoute(verb: 'POST', url: '/api/read-markers')] - public function create(int $threadId, int $lastReadPostId): DataResponse { + public function create(int $threadId = 0, int $lastReadPostId = 0, ?int $categoryId = null): DataResponse { try { $user = $this->userSession->getUser(); if (!$user) { return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED); } + // Category marker + if ($categoryId !== null) { + $marker = $this->readMarkerMapper->createOrUpdateCategoryMarker( + $user->getUID(), + $categoryId + ); + return new DataResponse($marker->jsonSerialize()); + } + + // Thread marker (default) + if ($threadId === 0 || $lastReadPostId === 0) { + return new DataResponse(['error' => 'threadId and lastReadPostId are required'], Http::STATUS_BAD_REQUEST); + } + $marker = $this->readMarkerMapper->createOrUpdate( $user->getUID(), $threadId, @@ -152,8 +182,8 @@ class ReadMarkerController extends OCSController { return new DataResponse($marker->jsonSerialize()); } catch (\Exception $e) { - $this->logger->error('Error marking thread as read: ' . $e->getMessage()); - return new DataResponse(['error' => 'Failed to mark thread as read'], Http::STATUS_INTERNAL_SERVER_ERROR); + $this->logger->error('Error marking as read: ' . $e->getMessage()); + return new DataResponse(['error' => 'Failed to mark as read'], Http::STATUS_INTERNAL_SERVER_ERROR); } } diff --git a/lib/Db/ReadMarker.php b/lib/Db/ReadMarker.php index cd4832c..b18979b 100644 --- a/lib/Db/ReadMarker.php +++ b/lib/Db/ReadMarker.php @@ -16,23 +16,30 @@ use OCP\AppFramework\Db\Entity; * @method void setId(int $value) * @method string getUserId() * @method void setUserId(string $value) - * @method int getThreadId() - * @method void setThreadId(int $value) - * @method int getLastReadPostId() - * @method void setLastReadPostId(int $value) + * @method int getEntityId() + * @method void setEntityId(int $value) + * @method string getMarkerType() + * @method void setMarkerType(string $value) + * @method int|null getLastReadPostId() + * @method void setLastReadPostId(?int $value) * @method int getReadAt() * @method void setReadAt(int $value) */ class ReadMarker extends Entity implements JsonSerializable { + public const TYPE_THREAD = 'thread'; + public const TYPE_CATEGORY = 'category'; + protected $userId; - protected $threadId; + protected $entityId; + protected $markerType; protected $lastReadPostId; protected $readAt; public function __construct() { $this->addType('id', 'integer'); $this->addType('userId', 'string'); - $this->addType('threadId', 'integer'); + $this->addType('entityId', 'integer'); + $this->addType('markerType', 'string'); $this->addType('lastReadPostId', 'integer'); $this->addType('readAt', 'integer'); } @@ -41,7 +48,8 @@ class ReadMarker extends Entity implements JsonSerializable { return [ 'id' => $this->getId(), 'userId' => $this->getUserId(), - 'threadId' => $this->getThreadId(), + 'entityId' => $this->getEntityId(), + 'markerType' => $this->getMarkerType(), 'lastReadPostId' => $this->getLastReadPostId(), 'readAt' => $this->getReadAt(), ]; diff --git a/lib/Db/ReadMarkerMapper.php b/lib/Db/ReadMarkerMapper.php index d1c335f..369f655 100644 --- a/lib/Db/ReadMarkerMapper.php +++ b/lib/Db/ReadMarkerMapper.php @@ -52,7 +52,10 @@ class ReadMarkerMapper extends QBMapper { $qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) ) ->andWhere( - $qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT)) + $qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_THREAD, IQueryBuilder::PARAM_STR)) + ) + ->andWhere( + $qb->expr()->eq('entity_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT)) ); return $this->findEntity($qb); } @@ -67,6 +70,9 @@ class ReadMarkerMapper extends QBMapper { ->from($this->getTableName()) ->where( $qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) + ) + ->andWhere( + $qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_THREAD, IQueryBuilder::PARAM_STR)) ); return $this->findEntities($qb); } @@ -91,7 +97,10 @@ class ReadMarkerMapper extends QBMapper { $qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) ) ->andWhere( - $qb->expr()->in('thread_id', $qb->createNamedParameter($threadIds, IQueryBuilder::PARAM_INT_ARRAY)) + $qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_THREAD, IQueryBuilder::PARAM_STR)) + ) + ->andWhere( + $qb->expr()->in('entity_id', $qb->createNamedParameter($threadIds, IQueryBuilder::PARAM_INT_ARRAY)) ); return $this->findEntities($qb); } @@ -119,13 +128,67 @@ class ReadMarkerMapper extends QBMapper { // Create new marker $marker = new ReadMarker(); $marker->setUserId($userId); - $marker->setThreadId($threadId); + $marker->setEntityId($threadId); + $marker->setMarkerType(ReadMarker::TYPE_THREAD); $marker->setLastReadPostId($lastReadPostId); $marker->setReadAt(time()); return $this->insert($marker); } } + /** + * Find all category read markers for a user + * + * @return array + */ + public function findCategoryMarkersByUserId(string $userId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) + ) + ->andWhere( + $qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_CATEGORY, IQueryBuilder::PARAM_STR)) + ); + return $this->findEntities($qb); + } + + /** + * Create or update a category read marker + */ + public function createOrUpdateCategoryMarker(string $userId, int $categoryId): ReadMarker { + try { + // Try to find existing marker + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) + ) + ->andWhere( + $qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_CATEGORY, IQueryBuilder::PARAM_STR)) + ) + ->andWhere( + $qb->expr()->eq('entity_id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT)) + ); + $marker = $this->findEntity($qb); + + // Always update the timestamp + $marker->setReadAt(time()); + return $this->update($marker); + } catch (DoesNotExistException $e) { + // Create new marker + $marker = new ReadMarker(); + $marker->setUserId($userId); + $marker->setEntityId($categoryId); + $marker->setMarkerType(ReadMarker::TYPE_CATEGORY); + $marker->setLastReadPostId(null); + $marker->setReadAt(time()); + return $this->insert($marker); + } + } + /** * @return array */ diff --git a/lib/Db/ThreadMapper.php b/lib/Db/ThreadMapper.php index 2b8ede7..e8885c0 100644 --- a/lib/Db/ThreadMapper.php +++ b/lib/Db/ThreadMapper.php @@ -220,6 +220,31 @@ class ThreadMapper extends QBMapper { return $this->findEntities($qb); } + /** + * Get last activity timestamp (max updated_at) per category + * + * @return array categoryId => lastActivityTimestamp + */ + public function getLastActivityByCategories(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('category_id') + ->selectAlias($qb->func()->max('updated_at'), 'last_activity') + ->from($this->getTableName()) + ->where($qb->expr()->isNull('deleted_at')) + ->andWhere($qb->expr()->eq('is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->groupBy('category_id'); + + $result = $qb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + + $map = []; + foreach ($rows as $row) { + $map[(int)$row['category_id']] = (int)$row['last_activity']; + } + return $map; + } + /** * Find recent threads in specified categories * diff --git a/lib/Migration/Version18Date20260214000000.php b/lib/Migration/Version18Date20260214000000.php new file mode 100644 index 0000000..a930fff --- /dev/null +++ b/lib/Migration/Version18Date20260214000000.php @@ -0,0 +1,96 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Forum\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Version 18 Migration: + * - Make forum_read_markers polymorphic by adding entity_id and marker_type columns + * - Make last_read_post_id nullable (category markers don't need it) + * - Copy thread_id values to entity_id + * - Drop old indexes + */ +class Version18Date20260214000000 extends SimpleMigrationStep { + public function __construct( + private IDBConnection $db, + ) { + } + + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + * @return ISchemaWrapper|null + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('forum_read_markers')) { + return null; + } + + $table = $schema->getTable('forum_read_markers'); + + // Add entity_id column (will be populated from thread_id in postSchemaChange) + if (!$table->hasColumn('entity_id')) { + $output->info('Forum: Adding entity_id column to forum_read_markers...'); + $table->addColumn('entity_id', 'bigint', [ + 'notnull' => true, + 'unsigned' => true, + 'default' => 0, + ]); + } + + // Add marker_type column + if (!$table->hasColumn('marker_type')) { + $output->info('Forum: Adding marker_type column to forum_read_markers...'); + $table->addColumn('marker_type', 'string', [ + 'notnull' => true, + 'length' => 16, + 'default' => 'thread', + ]); + } + + // Make last_read_post_id nullable (category markers don't use it) + $lastReadPostIdCol = $table->getColumn('last_read_post_id'); + $lastReadPostIdCol->setNotnull(false); + $lastReadPostIdCol->setDefault(null); + + // Drop old indexes + if ($table->hasIndex('forum_read_mark_uniq_idx')) { + $output->info('Forum: Dropping old unique index forum_read_mark_uniq_idx...'); + $table->dropIndex('forum_read_mark_uniq_idx'); + } + if ($table->hasIndex('forum_read_mark_tid_idx')) { + $output->info('Forum: Dropping old index forum_read_mark_tid_idx...'); + $table->dropIndex('forum_read_mark_tid_idx'); + } + + return $schema; + } + + /** + * Copy thread_id values to entity_id + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $output->info('Forum: Copying thread_id values to entity_id...'); + + $qb = $this->db->getQueryBuilder(); + $qb->update('forum_read_markers') + ->set('entity_id', 'thread_id'); + $qb->executeStatement(); + + $output->info('Forum: thread_id values copied to entity_id successfully.'); + } +} diff --git a/lib/Migration/Version19Date20260214000001.php b/lib/Migration/Version19Date20260214000001.php new file mode 100644 index 0000000..aa1009d --- /dev/null +++ b/lib/Migration/Version19Date20260214000001.php @@ -0,0 +1,57 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Forum\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Version 19 Migration: + * - Drop thread_id column (data already copied to entity_id in Version18) + * - Add new indexes for polymorphic read markers + */ +class Version19Date20260214000001 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + * @return ISchemaWrapper|null + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('forum_read_markers')) { + return null; + } + + $table = $schema->getTable('forum_read_markers'); + + // Drop thread_id column + if ($table->hasColumn('thread_id')) { + $output->info('Forum: Dropping thread_id column from forum_read_markers...'); + $table->dropColumn('thread_id'); + } + + // Add unique index on (user_id, marker_type, entity_id) + if (!$table->hasIndex('forum_read_mark_uniq_idx')) { + $output->info('Forum: Adding unique index forum_read_mark_uniq_idx...'); + $table->addUniqueIndex(['user_id', 'marker_type', 'entity_id'], 'forum_read_mark_uniq_idx'); + } + + // Add index on entity_id + if (!$table->hasIndex('forum_read_mark_eid_idx')) { + $output->info('Forum: Adding index forum_read_mark_eid_idx...'); + $table->addIndex(['entity_id'], 'forum_read_mark_eid_idx'); + } + + return $schema; + } +} diff --git a/openapi-full.json b/openapi-full.json index 3c5b303..36d532a 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1768,12 +1768,12 @@ "additionalProperties": { "type": "object", "required": [ - "threadId", + "entityId", "lastReadPostId", "readAt" ], "properties": { - "threadId": { + "entityId": { "type": "integer", "format": "int64" }, @@ -6237,6 +6237,15 @@ "default": "" } }, + { + "name": "markerType", + "in": "query", + "description": "Marker type ('thread' or 'category')", + "schema": { + "type": "string", + "default": "thread" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -6250,7 +6259,7 @@ ], "responses": { "200": { - "description": "Read markers returned (keyed by thread ID)", + "description": "Read markers returned (keyed by entity ID)", "content": { "application/json": { "schema": { @@ -6274,18 +6283,19 @@ "additionalProperties": { "type": "object", "required": [ - "threadId", + "entityId", "lastReadPostId", "readAt" ], "properties": { - "threadId": { + "entityId": { "type": "integer", "format": "int64" }, "lastReadPostId": { "type": "integer", - "format": "int64" + "format": "int64", + "nullable": true }, "readAt": { "type": "integer", @@ -6333,7 +6343,7 @@ }, "post": { "operationId": "read_marker-create", - "summary": "Mark a thread as read", + "summary": "Mark a thread or category as read", "tags": [ "read_marker" ], @@ -6346,25 +6356,30 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "threadId", - "lastReadPostId" - ], "properties": { "threadId": { "type": "integer", "format": "int64", + "default": 0, "description": "Thread ID" }, "lastReadPostId": { "type": "integer", "format": "int64", + "default": 0, "description": "Last read post ID" + }, + "categoryId": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "description": "Category ID (if provided, creates a category marker instead)" } } } @@ -6385,7 +6400,7 @@ ], "responses": { "200": { - "description": "Thread marked as read", + "description": "Marked as read", "content": { "application/json": { "schema": { diff --git a/openapi.json b/openapi.json index f5dfab0..82234ae 100644 --- a/openapi.json +++ b/openapi.json @@ -1768,12 +1768,12 @@ "additionalProperties": { "type": "object", "required": [ - "threadId", + "entityId", "lastReadPostId", "readAt" ], "properties": { - "threadId": { + "entityId": { "type": "integer", "format": "int64" }, @@ -6237,6 +6237,15 @@ "default": "" } }, + { + "name": "markerType", + "in": "query", + "description": "Marker type ('thread' or 'category')", + "schema": { + "type": "string", + "default": "thread" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -6250,7 +6259,7 @@ ], "responses": { "200": { - "description": "Read markers returned (keyed by thread ID)", + "description": "Read markers returned (keyed by entity ID)", "content": { "application/json": { "schema": { @@ -6274,18 +6283,19 @@ "additionalProperties": { "type": "object", "required": [ - "threadId", + "entityId", "lastReadPostId", "readAt" ], "properties": { - "threadId": { + "entityId": { "type": "integer", "format": "int64" }, "lastReadPostId": { "type": "integer", - "format": "int64" + "format": "int64", + "nullable": true }, "readAt": { "type": "integer", @@ -6333,7 +6343,7 @@ }, "post": { "operationId": "read_marker-create", - "summary": "Mark a thread as read", + "summary": "Mark a thread or category as read", "tags": [ "read_marker" ], @@ -6346,25 +6356,30 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "threadId", - "lastReadPostId" - ], "properties": { "threadId": { "type": "integer", "format": "int64", + "default": 0, "description": "Thread ID" }, "lastReadPostId": { "type": "integer", "format": "int64", + "default": 0, "description": "Last read post ID" + }, + "categoryId": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "description": "Category ID (if provided, creates a category marker instead)" } } } @@ -6385,7 +6400,7 @@ ], "responses": { "200": { - "description": "Thread marked as read", + "description": "Marked as read", "content": { "application/json": { "schema": { diff --git a/src/components/CategoryCard/CategoryCard.vue b/src/components/CategoryCard/CategoryCard.vue index b48495a..e1abce2 100644 --- a/src/components/CategoryCard/CategoryCard.vue +++ b/src/components/CategoryCard/CategoryCard.vue @@ -1,6 +1,7 @@