Files
nextcloud-forum/lib/Controller/CategoryController.php

604 lines
22 KiB
PHP

<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Controller;
use OCA\Forum\Attribute\RequirePermission;
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;
use OCA\Forum\Service\PermissionService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
class CategoryController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private CatHeaderMapper $catHeaderMapper,
private CategoryMapper $categoryMapper,
private CategoryPermMapper $categoryPermMapper,
private ThreadMapper $threadMapper,
private ReadMarkerMapper $readMarkerMapper,
private RoleMapper $roleMapper,
private PermissionService $permissionService,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}
/**
* Get all category headers with nested categories
*
* @param int<1, 100> $limit Maximum number of category headers to return
* @param int<0, max> $offset Offset for pagination
* @return DataResponse<Http::STATUS_OK, list<array<string, mixed>>, array{}>
*
* 200: Category headers with nested categories returned
*/
#[NoAdminRequired]
#[PublicPage]
#[ApiRoute(verb: 'GET', url: '/api/categories')]
public function index(int $limit = 100, int $offset = 0): DataResponse {
try {
// Fetch all headers, categories, and last activity timestamps
$headers = $this->catHeaderMapper->findAll();
$allCategories = $this->categoryMapper->findAll();
$lastActivityMap = $this->threadMapper->getLastActivityByCategories();
// Filter categories by canView permission
$user = $this->userSession->getUser();
$userId = $user ? $user->getUID() : null;
$accessibleCategoryIds = $this->permissionService->getAccessibleCategories($userId);
// Fetch category read markers for authenticated users
$readMarkerMap = [];
if ($user) {
$markers = $this->readMarkerMapper->findCategoryMarkersByUserId($user->getUID());
foreach ($markers as $marker) {
$readMarkerMap[$marker->getEntityId()] = $marker->getReadAt();
}
}
// Build a lookup map for resolving effective header IDs
$allCatsById = [];
foreach ($allCategories as $category) {
$allCatsById[$category->getId()] = $category;
}
// Group accessible categories by effective header_id
// Child categories inherit the header from their root ancestor
$categoriesByHeader = [];
foreach ($allCategories as $category) {
if (!in_array($category->getId(), $accessibleCategoryIds, true)) {
continue;
}
// Walk up the parent chain to find the effective header
$current = $category;
while ($current->getParentId() !== null && isset($allCatsById[$current->getParentId()])) {
$current = $allCatsById[$current->getParentId()];
}
$headerId = $current->getHeaderId();
if (!isset($categoriesByHeader[$headerId])) {
$categoriesByHeader[$headerId] = [];
}
$categoryData = $category->jsonSerialize();
$categoryData['lastActivityAt'] = $lastActivityMap[$category->getId()] ?? null;
$categoryData['readAt'] = $readMarkerMap[$category->getId()] ?? null;
$categoriesByHeader[$headerId][] = $categoryData;
}
// Build result with nested categories
$result = [];
foreach ($headers as $header) {
$categories = $categoriesByHeader[$header->getId()] ?? [];
$headerData = $header->jsonSerialize();
$headerData['categories'] = $categories;
$result[] = $headerData;
}
return new DataResponse(array_slice($result, $offset, $limit));
} catch (\Exception $e) {
$this->logger->error('Error fetching categories: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch categories'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get categories by header ID
*
* @param int $headerId Category header ID
* @param int<1, 100> $limit Maximum number of categories to return
* @param int<0, max> $offset Offset for pagination
* @return DataResponse<Http::STATUS_OK, list<array<string, mixed>>, array{}>
*
* 200: Categories returned
*/
#[NoAdminRequired]
#[PublicPage]
#[ApiRoute(verb: 'GET', url: '/api/headers/{headerId}/categories')]
public function byHeader(int $headerId, int $limit = 100, int $offset = 0): DataResponse {
try {
$user = $this->userSession->getUser();
$userId = $user ? $user->getUID() : null;
$accessibleCategoryIds = $this->permissionService->getAccessibleCategories($userId);
$categories = $this->categoryMapper->findByHeaderId($headerId);
$filtered = array_filter($categories, fn ($cat) => in_array($cat->getId(), $accessibleCategoryIds, true));
return new DataResponse(array_slice(array_values(array_map(fn ($cat) => $cat->jsonSerialize(), $filtered)), $offset, $limit));
} catch (\Exception $e) {
$this->logger->error('Error fetching categories by header: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch categories'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get a single category
*
* @param int $id Category ID
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: Category returned
*/
#[NoAdminRequired]
#[PublicPage]
#[ApiRoute(verb: 'GET', url: '/api/categories/{id}')]
public function show(int $id): DataResponse {
try {
$category = $this->categoryMapper->find($id);
return new DataResponse($category->jsonSerialize());
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Category not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error fetching category: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch category'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get a category by slug
*
* @param string $slug Category slug
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: Category returned
*/
#[NoAdminRequired]
#[PublicPage]
#[ApiRoute(verb: 'GET', url: '/api/categories/slug/{slug}')]
public function bySlug(string $slug): DataResponse {
try {
$category = $this->categoryMapper->findBySlug($slug);
return new DataResponse($category->jsonSerialize());
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Category not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error fetching category by slug: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch category'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Create a new category
*
* @param int|null $headerId Category header ID (required for top-level categories)
* @param string $name Category name
* @param string $slug Category slug
* @param string|null $description Category description
* @param int $sortOrder Sort order
* @param string|null $color Category color (hex, e.g. #dc2626)
* @param string|null $textColor Text color mode ('light' or 'dark')
* @param int|null $parentId Parent category ID (null for top-level categories)
* @param bool $hideChildrenOnCard Whether to hide child categories on the parent card
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>
*
* 201: Category created
*/
#[NoAdminRequired]
#[RequirePermission('canEditCategories')]
#[ApiRoute(verb: 'POST', url: '/api/categories')]
public function create(?int $headerId = null, string $name = '', string $slug = '', ?string $description = null, int $sortOrder = 0, ?string $color = null, ?string $textColor = null, ?int $parentId = null, bool $hideChildrenOnCard = false): DataResponse {
try {
// Validate: either headerId (top-level) or parentId (child) must be set
if ($parentId !== null) {
// Validate parent exists
try {
$this->categoryMapper->find($parentId);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Parent category not found'], Http::STATUS_NOT_FOUND);
}
// Child categories don't have their own header
$headerId = null;
} elseif ($headerId === null) {
return new DataResponse(['error' => 'Either headerId or parentId must be provided'], Http::STATUS_BAD_REQUEST);
}
$category = new \OCA\Forum\Db\Category();
$category->setHeaderId($headerId);
$category->setParentId($parentId);
$category->setName($name);
$category->setSlug($slug);
$category->setDescription($description);
$category->setSortOrder($sortOrder);
$category->setColor($color);
$category->setTextColor($textColor);
$category->setHideChildrenOnCard($hideChildrenOnCard);
$category->setThreadCount(0);
$category->setPostCount(0);
$category->setCreatedAt(time());
$category->setUpdatedAt(time());
/** @var \OCA\Forum\Db\Category */
$createdCategory = $this->categoryMapper->insert($category);
return new DataResponse($createdCategory->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error creating category: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to create category'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Update a category
*
* @param int $id Category ID
* @param int|null $headerId Category header ID
* @param string|null $name Category name
* @param string|null $description Category description
* @param string|null $slug Category slug
* @param int|null $sortOrder Sort order
* @param string|null $color Category color (hex, e.g. #dc2626)
* @param string|null $textColor Text color mode ('light' or 'dark')
* @param string|null $parentId Parent category ID ('__unset__' = not provided, null = top-level, int = child)
* @param bool|null $hideChildrenOnCard Whether to hide child categories on the parent card
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: Category updated
*/
#[NoAdminRequired]
#[RequirePermission('canEditCategories')]
#[ApiRoute(verb: 'PUT', url: '/api/categories/{id}')]
public function update(int $id, ?int $headerId = null, ?string $name = null, ?string $description = null, ?string $slug = null, ?int $sortOrder = null, ?string $color = '__unset__', ?string $textColor = '__unset__', string|int|null $parentId = '__unset__', ?bool $hideChildrenOnCard = null): DataResponse {
try {
$category = $this->categoryMapper->find($id);
// Handle parentId changes
if ($parentId !== '__unset__') {
if ($parentId !== null) {
$parentIdInt = (int)$parentId;
// Validate parent exists
try {
$this->categoryMapper->find($parentIdInt);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Parent category not found'], Http::STATUS_NOT_FOUND);
}
// Prevent circular references: walk up from proposed parent
$current = $parentIdInt;
while ($current !== null) {
if ($current === $id) {
return new DataResponse(['error' => 'Cannot set a descendant as parent (circular reference)'], Http::STATUS_BAD_REQUEST);
}
try {
$parentCat = $this->categoryMapper->find($current);
$current = $parentCat->getParentId();
} catch (DoesNotExistException $e) {
break;
}
}
$category->setParentId($parentIdInt);
$category->setHeaderId(null);
} else {
// Moving to top-level: need a headerId
$category->setParentId(null);
if ($headerId !== null) {
$category->setHeaderId($headerId);
}
}
} elseif ($headerId !== null) {
$category->setHeaderId($headerId);
}
if ($name !== null) {
$category->setName($name);
}
if ($description !== null) {
$category->setDescription($description);
}
if ($slug !== null) {
$category->setSlug($slug);
}
if ($sortOrder !== null) {
$category->setSortOrder($sortOrder);
}
if ($color !== '__unset__') {
$category->setColor($color);
}
if ($textColor !== '__unset__') {
$category->setTextColor($textColor);
}
if ($hideChildrenOnCard !== null) {
$category->setHideChildrenOnCard($hideChildrenOnCard);
}
$category->setUpdatedAt(time());
/** @var \OCA\Forum\Db\Category */
$updatedCategory = $this->categoryMapper->update($category);
return new DataResponse($updatedCategory->jsonSerialize());
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Category not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error updating category: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to update category'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get thread count for a category
*
* @param int $id Category ID
* @return DataResponse<Http::STATUS_OK, array{count: int}, array{}>
*
* 200: Thread count returned
*/
#[NoAdminRequired]
#[PublicPage]
#[ApiRoute(verb: 'GET', url: '/api/categories/{id}/thread-count')]
public function getThreadCount(int $id): DataResponse {
try {
$this->categoryMapper->find($id);
$count = $this->threadMapper->countByCategoryId($id);
return new DataResponse(['count' => $count]);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Category not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error fetching thread count: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch thread count'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Delete a category
*
* @param int $id Category ID
* @param int|null $migrateToCategoryId Category ID to migrate threads to (null to soft-delete threads)
* @return DataResponse<Http::STATUS_OK, array{success: bool, threadsAffected?: int}, array{}>
*
* 200: Category deleted
*/
#[NoAdminRequired]
#[RequirePermission('canEditCategories')]
#[ApiRoute(verb: 'DELETE', url: '/api/categories/{id}')]
public function destroy(int $id, ?int $migrateToCategoryId = null): DataResponse {
try {
$category = $this->categoryMapper->find($id);
// Re-parent children: move direct children to this category's parent
$children = $this->categoryMapper->findByParentId($id);
foreach ($children as $child) {
$child->setParentId($category->getParentId());
// If deleted category was top-level, children become top-level under the same header
if ($category->getParentId() === null) {
$child->setHeaderId($category->getHeaderId());
}
$child->setUpdatedAt(time());
$this->categoryMapper->update($child);
}
$threadsAffected = 0;
// Handle threads migration or soft-delete
if ($migrateToCategoryId !== null) {
// Verify target category exists
try {
$this->categoryMapper->find($migrateToCategoryId);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Target category not found'], Http::STATUS_NOT_FOUND);
}
// Move threads to the target category
$threadsAffected = $this->threadMapper->moveToCategoryId($id, $migrateToCategoryId);
} else {
// Soft delete all threads in this category
$threadsAffected = $this->threadMapper->softDeleteByCategoryId($id);
}
// Delete the category
$this->categoryMapper->delete($category);
return new DataResponse([
'success' => true,
'threadsAffected' => $threadsAffected,
]);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Category not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error deleting category: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to delete category'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Check if current user has a specific permission on a category
*
* @param int $id Category ID
* @param string $permission Permission name (canView, canPost, canReply, canModerate)
* @return DataResponse<Http::STATUS_OK, array{hasPermission: bool}, array{}>
*
* 200: Permission check result
*/
#[NoAdminRequired]
#[PublicPage]
#[ApiRoute(verb: 'GET', url: '/api/categories/{id}/permissions/{permission}')]
public function checkPermission(int $id, string $permission): DataResponse {
try {
$user = $this->userSession->getUser();
$userId = $user?->getUID();
$hasPermission = $this->permissionService->hasCategoryPermission($userId, $id, $permission);
return new DataResponse(['hasPermission' => $hasPermission]);
} catch (\Exception $e) {
$this->logger->error("Error checking permission {$permission} for category {$id}: " . $e->getMessage());
return new DataResponse(['hasPermission' => false]);
}
}
/**
* Get permissions for a category
*
* @param int $id Category ID
* @param int<1, 100> $limit Maximum number of permissions to return
* @param int<0, max> $offset Offset for pagination
* @return DataResponse<Http::STATUS_OK, list<array<string, mixed>>, array{}>
*
* 200: Permissions returned
*/
#[NoAdminRequired]
#[RequirePermission('canEditCategories')]
#[ApiRoute(verb: 'GET', url: '/api/categories/{id}/permissions')]
public function getPermissions(int $id, int $limit = 100, int $offset = 0): DataResponse {
try {
// Exclude Admin role - it has hardcoded full access to all categories
$permissions = array_slice($this->categoryPermMapper->findByCategoryIdExcludingAdmin($id), $offset, $limit);
return new DataResponse(array_map(fn ($perm) => $perm->jsonSerialize(), $permissions));
} catch (\Exception $e) {
$this->logger->error('Error fetching category permissions: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch permissions'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Update permissions for a category
*
* @param int $id Category ID
* @param list<array{roleId: int, canView: bool, canPost: bool, canReply: bool, canModerate: bool}> $permissions Role permissions array
* @param list<array{teamId: string, canView: bool, canPost: bool, canReply: bool, canModerate: bool}> $teamPermissions Team permissions array
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
*
* 200: Permissions updated
*/
#[NoAdminRequired]
#[RequirePermission('canEditCategories')]
#[ApiRoute(verb: 'POST', url: '/api/categories/{id}/permissions')]
public function updatePermissions(int $id, array $permissions, array $teamPermissions = []): DataResponse {
try {
// Verify category exists
$this->categoryMapper->find($id);
// Delete existing role permissions for this category
$this->categoryPermMapper->deleteByCategoryIdAndTargetType($id, CategoryPerm::TARGET_TYPE_ROLE);
// Filter out Admin role - it has hardcoded full access
$filteredPermissions = array_filter($permissions, function ($perm) {
$roleId = $perm['roleId'] ?? null;
if ($roleId === null) {
return false;
}
try {
$role = $this->roleMapper->find($roleId);
return $role->getRoleType() !== Role::ROLE_TYPE_ADMIN;
} catch (DoesNotExistException $e) {
return false;
}
});
// Insert role permissions
foreach ($filteredPermissions as $perm) {
$categoryPerm = new CategoryPerm();
$categoryPerm->setCategoryId($id);
$categoryPerm->setTargetType(CategoryPerm::TARGET_TYPE_ROLE);
$categoryPerm->setTargetId((string)$perm['roleId']);
$categoryPerm->setCanView($perm['canView'] ?? false);
$categoryPerm->setCanPost($perm['canPost'] ?? $perm['canView'] ?? false);
$categoryPerm->setCanReply($perm['canReply'] ?? $perm['canPost'] ?? $perm['canView'] ?? false);
// Guest and Default roles never have moderate permission
try {
$role = $this->roleMapper->find($perm['roleId']);
$canModerate = $role->isModeratorRestricted() ? false : ($perm['canModerate'] ?? false);
$categoryPerm->setCanModerate($canModerate);
} catch (DoesNotExistException $e) {
$categoryPerm->setCanModerate(false);
}
$this->categoryPermMapper->insert($categoryPerm);
}
// Delete existing team permissions for this category and re-insert
$this->categoryPermMapper->deleteByCategoryIdAndTargetType($id, CategoryPerm::TARGET_TYPE_TEAM);
foreach ($teamPermissions as $perm) {
$teamId = $perm['teamId'] ?? null;
if ($teamId === null) {
continue;
}
$categoryPerm = new CategoryPerm();
$categoryPerm->setCategoryId($id);
$categoryPerm->setTargetType(CategoryPerm::TARGET_TYPE_TEAM);
$categoryPerm->setTargetId((string)$teamId);
$categoryPerm->setCanView($perm['canView'] ?? false);
$categoryPerm->setCanPost($perm['canPost'] ?? false);
$categoryPerm->setCanReply($perm['canReply'] ?? false);
$categoryPerm->setCanModerate($perm['canModerate'] ?? false);
$this->categoryPermMapper->insert($categoryPerm);
}
return new DataResponse(['success' => true]);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Category not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error updating category permissions: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to update permissions'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Reorder categories
*
* @param list<array{id: int, sortOrder: int}> $categories Array of categories with new sort orders
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
*
* 200: Categories reordered successfully
*/
#[NoAdminRequired]
#[RequirePermission('canEditCategories')]
#[ApiRoute(verb: 'POST', url: '/api/categories/reorder')]
public function reorder(array $categories): DataResponse {
try {
foreach ($categories as $categoryData) {
$category = $this->categoryMapper->find($categoryData['id']);
$category->setSortOrder($categoryData['sortOrder']);
$this->categoryMapper->update($category);
}
return new DataResponse(['success' => true]);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Category not found'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Error reordering categories: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to reorder categories'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}