Compare commits

...

39 Commits

Author SHA1 Message Date
be51e8a1a5 chore(master): release 0.10.0 2025-11-23 02:11:43 +02:00
53875b1eef fix(AdminDashboard): exclude thread posts from post count 2025-11-23 02:08:03 +02:00
0f9d5ea9a5 fix: modal actions spacing 2025-11-23 02:07:30 +02:00
4708d8cf87 chore: exclude gen/ changes from php test trigger 2025-11-23 02:07:03 +02:00
20a15b42d9 chore(test): swap phpunit local/docker 2025-11-23 00:17:15 +02:00
7a1853935e chore: run php tests on commit 2025-11-22 23:48:42 +02:00
04ec7ffcf8 test: add/update role & permission tests 2025-11-22 23:47:53 +02:00
c9a76e5cd9 feat(Roles): admin always has full permissions 2025-11-22 23:37:05 +02:00
94787052ef refactor(Role): improve role logic in UI 2025-11-22 23:07:19 +02:00
e20bfdadab refactor(Roles): update UserRoleController & SetRole command to use UserRoleService 2025-11-22 23:05:02 +02:00
328b37be6e fix(Roles): prevent deleting system roles on backend 2025-11-22 23:03:58 +02:00
c7f84d4a18 fix(UserEventListener): add User role to newly created users 2025-11-22 23:03:33 +02:00
d09987600b chore(master): release 0.9.2 2025-11-22 22:03:04 +02:00
dcdcde31ed chore: allow blank issue templates 2025-11-22 21:53:14 +02:00
f66169288e build: reduce test workflow count 2025-11-22 21:50:27 +02:00
7a17dbc524 docs: update README.md 2025-11-22 21:44:09 +02:00
c1443014b5 build: update phpunit workflows 2025-11-22 21:44:02 +02:00
4c2e47d86b build: update phpunit workflows 2025-11-22 21:38:46 +02:00
8408402148 fix(l10n): plural tokens + text alignment strings 2025-11-22 21:18:58 +02:00
3d113f1f31 fix(l10n): update translation source strings 2025-11-22 20:59:43 +02:00
48b7679e3b chore: remove unused file 2025-11-22 20:58:36 +02:00
5f0317b153 build: update php test versions matrix 2025-11-22 03:39:45 +02:00
56dc0049b8 build: include all composer.lock files 2025-11-22 03:37:32 +02:00
7519088e2b build: update php test version matrix 2025-11-22 03:34:22 +02:00
0f3be447fa build: add phpunit-pgsql task 2025-11-22 03:33:02 +02:00
f73d902962 build: update phpunit workflow min php version 2025-11-22 03:29:08 +02:00
4a9ae9bfc6 build: update phpunit workflow min php version 2025-11-22 03:27:00 +02:00
37012590a1 build: update test workflow 2025-11-22 03:21:12 +02:00
00b80b817d build: include composer.lock 2025-11-22 03:20:56 +02:00
3472e95065 docs: update README.md badges 2025-11-22 03:15:11 +02:00
7fde88a158 build: add phpunit-mysql workflow 2025-11-22 02:49:44 +02:00
5ebeb56636 test: add missing tests 2025-11-22 02:29:36 +02:00
a66bcd4612 fix(PostController): exclude first posts from post_count fields 2025-11-22 02:29:14 +02:00
36d8ecd5bb test: fix failing tests 2025-11-22 02:01:31 +02:00
257a12dfc4 fix: post counts in threads/categories 2025-11-22 01:55:06 +02:00
b67813fa34 fix(SeedHelper): subscribe author to welcome thread 2025-11-22 01:39:05 +02:00
d6c6626bad fix(l10n): bbcode help dialog strings 2025-11-22 00:51:26 +02:00
9837fc4683 chore(master): release 0.9.1 2025-11-22 00:42:58 +02:00
a3b0582d2c fix(l10n): fix welcome post & bbcode example strings 2025-11-22 00:39:46 +02:00
70 changed files with 9919 additions and 756 deletions

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: Nextcloud Community Forum
url: https://help.nextcloud.com

201
.github/workflows/phpunit-mysql.yml vendored Normal file
View File

@@ -0,0 +1,201 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: PHPUnit MySQL
on: pull_request
permissions:
contents: read
concurrency:
group: phpunit-mysql-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Checkout app
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Get supported server versions
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
- name: Build test matrix
id: set-matrix
run: |
# Get server branches from version matrix
BRANCHES='${{ steps.versions.outputs.branches }}'
# Build minimal matrix: min Nextcloud with PHP 8.2, max Nextcloud with PHP 8.3
MATRIX=$(jq -nc \
--argjson branches "$BRANCHES" \
'{include: [{"php-versions": "8.2", "mysql-versions": "8.4", "server-versions": $branches[0]}, {"php-versions": "8.3", "mysql-versions": "8.4", "server-versions": $branches[-1]}]}'
)
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
echo "Generated matrix: $MATRIX"
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
src: ${{ steps.changes.outputs.src}}
steps:
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
continue-on-error: true
with:
filters: |
src:
- '.github/workflows/**'
- 'appinfo/**'
- 'lib/**'
- 'templates/**'
- 'tests/**'
- 'vendor/**'
- 'vendor-bin/**'
- '.php-cs-fixer.dist.php'
- 'composer.json'
- 'composer.lock'
phpunit-mysql:
runs-on: ubuntu-latest
needs: [changes, matrix]
if: needs.changes.outputs.src != 'false'
strategy:
matrix: ${{ fromJson(needs.matrix.outputs.matrix) }}
name: MySQL ${{ matrix.mysql-versions }} PHP ${{ matrix.php-versions }} Nextcloud ${{ matrix.server-versions }}
services:
mysql:
image: ghcr.io/nextcloud/continuous-integration-mysql-${{ matrix.mysql-versions }}:latest
ports:
- 4444:3306/tcp
env:
MYSQL_ROOT_PASSWORD: rootpassword
options: --health-cmd="mysqladmin ping" --health-interval 5s --health-timeout 2s --health-retries 10
steps:
- name: Checkout app
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
path: app-checkout
- name: Detect app ID from appinfo/info.xml
run: |
APP_ID=$(grep -oP '(?<=<id>)[^<]+' app-checkout/appinfo/info.xml | head -1)
echo "APP_NAME=$APP_ID" >> $GITHUB_ENV
echo "Detected app ID: $APP_ID"
- name: Checkout server
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
submodules: true
repository: nextcloud/server
ref: ${{ matrix.server-versions }}
- name: Checkout app
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
path: apps/${{ env.APP_NAME }}
- name: Set up php ${{ matrix.php-versions }}
uses: shivammathur/setup-php@0f7f1d08e3e32076e51cae65eb0b0c871405b16e # v2.34.1
with:
php-version: ${{ matrix.php-versions }}
# https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, mysql, pdo_mysql
coverage: none
ini-file: development
# Temporary workaround for missing pcntl_* in PHP 8.3
ini-values: disable_functions=
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Enable ONLY_FULL_GROUP_BY MySQL option
run: |
echo "SET GLOBAL sql_mode=(SELECT CONCAT(@@sql_mode,',ONLY_FULL_GROUP_BY'));" | mysql -h 127.0.0.1 -P 4444 -u root -prootpassword
echo 'SELECT @@sql_mode;' | mysql -h 127.0.0.1 -P 4444 -u root -prootpassword
- name: Check composer file existence
id: check_composer
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0
with:
files: apps/${{ env.APP_NAME }}/composer.json
- name: Set up dependencies
# Only run if phpunit config file exists
if: steps.check_composer.outputs.files_exists == 'true'
working-directory: apps/${{ env.APP_NAME }}
run: |
composer remove nextcloud/ocp --dev --no-scripts
composer i
- name: Set up Nextcloud
env:
DB_PORT: 4444
run: |
mkdir data
./occ maintenance:install --verbose --database=mysql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin
./occ app:enable --force ${{ env.APP_NAME }}
- name: Check PHPUnit script is defined
id: check_phpunit
continue-on-error: true
working-directory: apps/${{ env.APP_NAME }}
run: |
composer run --list | grep '^ test:unit ' | wc -l | grep 1
- name: PHPUnit
# Only run if phpunit config file exists
if: steps.check_phpunit.outcome == 'success'
working-directory: apps/${{ env.APP_NAME }}
run: composer run test:unit
- name: Print logs
if: always()
run: |
cat data/nextcloud.log
- name: Skipped
# Fail the action when unit tests are not specified
if: steps.check_phpunit.outcome == 'failure'
run: |
echo 'PHPUnit tests are not specified in composer.json scripts'
exit 1
summary:
permissions:
contents: none
runs-on: ubuntu-latest
needs: [changes, phpunit-mysql]
if: always()
name: phpunit-mysql-summary
steps:
- name: Summary status
run: if ${{ needs.changes.outputs.src != 'false' && needs.phpunit-mysql.result != 'success' }}; then exit 1; fi

216
.github/workflows/phpunit-pgsql.yml vendored Normal file
View File

@@ -0,0 +1,216 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: PHPUnit PostgreSQL
on: pull_request
permissions:
contents: read
concurrency:
group: phpunit-pgsql-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Checkout app
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Get supported server versions
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
- name: Build test matrix
id: set-matrix
run: |
# Get server branches from version matrix
BRANCHES='${{ steps.versions.outputs.branches }}'
# Build minimal matrix: only latest Nextcloud with PHP 8.3
MATRIX=$(jq -nc \
--argjson branches "$BRANCHES" \
'{include: [{"php-versions": "8.3", "server-versions": $branches[-1]}]}'
)
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
echo "Generated matrix: $MATRIX"
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
src: ${{ steps.changes.outputs.src }}
steps:
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
continue-on-error: true
with:
filters: |
src:
- '.github/workflows/**'
- 'appinfo/**'
- 'lib/**'
- 'templates/**'
- 'tests/**'
- 'vendor/**'
- 'vendor-bin/**'
- '.php-cs-fixer.dist.php'
- 'composer.json'
- 'composer.lock'
phpunit-pgsql:
runs-on: ubuntu-latest
needs: [changes, matrix]
if: needs.changes.outputs.src != 'false'
strategy:
matrix: ${{ fromJson(needs.matrix.outputs.matrix) }}
name: PostgreSQL PHP ${{ matrix.php-versions }} Nextcloud ${{ matrix.server-versions }}
services:
postgres:
image: ghcr.io/nextcloud/continuous-integration-postgres-16:latest # zizmor: ignore[unpinned-images]
ports:
- 4444:5432/tcp
env:
POSTGRES_USER: root
POSTGRES_PASSWORD: rootpassword
POSTGRES_DB: nextcloud
options: --health-cmd pg_isready --health-interval 5s --health-timeout 2s --health-retries 5
steps:
- name: Checkout app
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
path: app-checkout
- name: Detect app ID from appinfo/info.xml
run: |
APP_ID=$(grep -oP '(?<=<id>)[^<]+' app-checkout/appinfo/info.xml | head -1)
echo "APP_NAME=$APP_ID" >> $GITHUB_ENV
echo "Detected app ID: $APP_ID"
- name: Checkout server
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
submodules: true
repository: nextcloud/server
ref: ${{ matrix.server-versions }}
- name: Checkout app
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
path: apps/${{ env.APP_NAME }}
- name: Set up php ${{ matrix.php-versions }}
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2.35.5
with:
php-version: ${{ matrix.php-versions }}
# https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, pgsql, pdo_pgsql
coverage: none
ini-file: development
# Temporary workaround for missing pcntl_* in PHP 8.3
ini-values: disable_functions=
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check composer file existence
id: check_composer
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0
with:
files: apps/${{ env.APP_NAME }}/composer.json
- name: Set up dependencies
# Only run if phpunit config file exists
if: steps.check_composer.outputs.files_exists == 'true'
working-directory: apps/${{ env.APP_NAME }}
run: |
composer remove nextcloud/ocp --dev --no-scripts
composer i
- name: Set up Nextcloud
env:
DB_PORT: 4444
run: |
mkdir data
./occ maintenance:install --verbose --database=pgsql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin
./occ app:enable --force ${{ env.APP_NAME }}
- name: Check PHPUnit script is defined
id: check_phpunit
continue-on-error: true
working-directory: apps/${{ env.APP_NAME }}
run: |
composer run --list | grep '^ test:unit ' | wc -l | grep 1
- name: PHPUnit
# Only run if phpunit config file exists
if: steps.check_phpunit.outcome == 'success'
working-directory: apps/${{ env.APP_NAME }}
run: composer run test:unit
- name: Check PHPUnit integration script is defined
id: check_integration
continue-on-error: true
working-directory: apps/${{ env.APP_NAME }}
run: |
composer run --list | grep '^ test:integration ' | wc -l | grep 1
- name: Run Nextcloud
# Only run if phpunit integration config file exists
if: steps.check_integration.outcome == 'success'
run: php -S localhost:8080 &
- name: PHPUnit integration
# Only run if phpunit integration config file exists
if: steps.check_integration.outcome == 'success'
working-directory: apps/${{ env.APP_NAME }}
run: composer run test:integration
- name: Print logs
if: always()
run: |
cat data/nextcloud.log
- name: Skipped
# Fail the action when neither unit nor integration tests ran
if: steps.check_phpunit.outcome == 'failure' && steps.check_integration.outcome == 'failure'
run: |
echo 'Neither PHPUnit nor PHPUnit integration tests are specified in composer.json scripts'
exit 1
summary:
permissions:
contents: none
runs-on: ubuntu-latest
needs: [changes, phpunit-pgsql]
if: always()
name: phpunit-pgsql-summary
steps:
- name: Summary status
run: if ${{ needs.changes.outputs.src != 'false' && needs.phpunit-pgsql.result != 'success' }}; then exit 1; fi

1
.gitignore vendored
View File

@@ -12,7 +12,6 @@
/js
/css
.DS_Store
composer.lock
build/
tsconfig.app.tsbuildinfo
.env

View File

@@ -2,9 +2,16 @@ module.exports = {
'*.{ts,vue}': ['eslint --fix'],
'*.{scss,vue,ts,md}': ['prettier --write'],
'*.json': (files) => {
const filtered = files.filter(file => !file.includes('openapi.json'));
return filtered.length > 0 ? `prettier --write ${filtered.join(' ')}` : [];
const filtered = files.filter(file => !file.includes('openapi.json'))
return filtered.length > 0 ? `prettier --write ${filtered.join(' ')}` : []
},
'*.php': (files) => {
const nonGenFiles = files.filter(file => !file.includes('/gen/'))
const commands = []
if (nonGenFiles.length > 0) {
commands.push('make php-cs-fixer', 'make test')
}
return commands
},
'*.php': [() => 'make php-cs-fixer'],
'*Controller.php': [() => 'make openapi', () => 'git add openapi.json'],
}

View File

@@ -1 +1 @@
{".":"0.9.0"}
{".":"0.10.0"}

View File

@@ -1,5 +1,39 @@
# Changelog
## [0.10.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.9.2...v0.10.0) (2025-11-23)
### Features
* **Roles:** admin always has full permissions ([c9a76e5](https://github.com/chenasraf/nextcloud-forum/commit/c9a76e5cd97df0c82bb79825799eeba7ce66086e))
### Bug Fixes
* **AdminDashboard:** exclude thread posts from post count ([53875b1](https://github.com/chenasraf/nextcloud-forum/commit/53875b1eefaddcbe8c4024c98834e44c3a01aeb0))
* modal actions spacing ([0f9d5ea](https://github.com/chenasraf/nextcloud-forum/commit/0f9d5ea9a5a99dda9bb351ed98d91ae880ddf64f))
* **Roles:** prevent deleting system roles on backend ([328b37b](https://github.com/chenasraf/nextcloud-forum/commit/328b37be6eec2b0001517ef74779565457de7213))
* **UserEventListener:** add User role to newly created users ([c7f84d4](https://github.com/chenasraf/nextcloud-forum/commit/c7f84d4a186ab7832d5fa96c2143bef30ddf3a85))
## [0.9.2](https://github.com/chenasraf/nextcloud-forum/compare/v0.9.1...v0.9.2) (2025-11-22)
### Bug Fixes
* **l10n:** bbcode help dialog strings ([d6c6626](https://github.com/chenasraf/nextcloud-forum/commit/d6c6626badf90636aae4f59fbe4765a5f786f5ab))
* **l10n:** plural tokens + text alignment strings ([8408402](https://github.com/chenasraf/nextcloud-forum/commit/8408402148c9935b50df1f75915cffba7d76b043))
* **l10n:** update translation source strings ([3d113f1](https://github.com/chenasraf/nextcloud-forum/commit/3d113f1f31887b7849753d07712a548392f574d6))
* post counts in threads/categories ([257a12d](https://github.com/chenasraf/nextcloud-forum/commit/257a12dfc43d7784d7e63ab79a8545d80022251e))
* **PostController:** exclude first posts from post_count fields ([a66bcd4](https://github.com/chenasraf/nextcloud-forum/commit/a66bcd4612a1889624e608e9e0207d7e09fc32df))
* **SeedHelper:** subscribe author to welcome thread ([b67813f](https://github.com/chenasraf/nextcloud-forum/commit/b67813fa34746de8f999b16e609c6ee7a9458e9b))
## [0.9.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.9.0...v0.9.1) (2025-11-21)
### Bug Fixes
* **l10n:** fix welcome post & bbcode example strings ([a3b0582](https://github.com/chenasraf/nextcloud-forum/commit/a3b0582d2cce2c22cd5070f3c335a732c25c5e67))
## [0.9.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.8.2...v0.9.0) (2025-11-21)

View File

@@ -230,7 +230,7 @@ test: composer
exit 1; \
fi; \
echo "\x1b[32mUsing Nextcloud root: $$NC_ROOT\x1b[0m"; \
NEXTCLOUD_ROOT="$$NC_ROOT" $(CURDIR)/vendor/phpunit/phpunit/phpunit -c tests/phpunit.local.xml; \
NEXTCLOUD_ROOT="$$NC_ROOT" $(CURDIR)/vendor/phpunit/phpunit/phpunit -c tests/phpunit.xml; \
if [ -f tests/phpunit.integration.xml ]; then \
NEXTCLOUD_ROOT="$$NC_ROOT" $(CURDIR)/vendor/phpunit/phpunit/phpunit -c tests/phpunit.integration.xml; \
fi
@@ -265,7 +265,7 @@ test-docker:
exit 1; \
fi; \
echo "\x1b[33mRunning tests in container $$CONTAINER_ID for app $$APP_DIR\x1b[0m"; \
docker exec $$CONTAINER_ID phpunit -c apps-shared/$$APP_DIR/tests/phpunit.xml
docker exec $$CONTAINER_ID phpunit -c apps-shared/$$APP_DIR/tests/phpunit.docker.xml
# lint:
# - Lint JS via pnpm and PHP via composer script "lint"

View File

@@ -5,7 +5,9 @@ SPDX-License-Identifier: CC0-1.0
# Nextcloud Forum
![GitHub Release](https://img.shields.io/github/v/release/chenasraf/nextcloud-forum)
[![GitHub Release](https://img.shields.io/github/v/release/chenasraf/nextcloud-forum?color=blue)](https://github.com/chenasraf/nextcloud-forum/releases/latest)
[![PHPUnit MySQL](https://github.com/chenasraf/nextcloud-forum/actions/workflows/phpunit-mysql.yml/badge.svg)](https://github.com/chenasraf/nextcloud-forum/actions/workflows/phpunit-mysql.yml)
[![PHPUnit PostgreSQL](https://github.com/chenasraf/nextcloud-forum/actions/workflows/phpunit-pgsql.yml/badge.svg)](https://github.com/chenasraf/nextcloud-forum/actions/workflows/phpunit-pgsql.yml)
A full-featured forum application for Nextcloud, allowing users to create discussion categories,
threads, and posts within their Nextcloud instance.

View File

@@ -13,7 +13,7 @@ Create discussions, share ideas, and collaborate with your community directly in
**⚠️ Early Development Notice:**
This app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.
**Key Features:**
**Key features:**
- **Thread-based Discussions** - Create and reply to organized discussion threads
- **Category Organization** - Structure your forum with customizable categories and headers
- **Rich Text Formatting** - Use BBCode for formatting posts with bold, italic, links, images, code blocks and more
@@ -26,7 +26,7 @@ This app is in early stages of development. While functional, you may encounter
- **Admin Tools** - Manage categories, roles, BBCodes, and forum settings
- **Moderation Tools** - Pin, lock, and manage threads and posts
**Perfect For:**
**Perfect for:**
- Team discussions and collaboration
- Community forums
- Support channels
@@ -36,7 +36,7 @@ This app is in early stages of development. While functional, you may encounter
The forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.
]]></description>
<version>0.9.0</version>
<version>0.10.0</version>
<licence>agpl</licence>
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
<namespace>Forum</namespace>

3039
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,7 @@ declare(strict_types=1);
namespace OCA\Forum\Command;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\UserRole;
use OCA\Forum\Db\UserRoleMapper;
use OCA\Forum\Service\UserRoleService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\IUserManager;
@@ -21,7 +20,7 @@ use Symfony\Component\Console\Output\OutputInterface;
class SetRole extends Command {
public function __construct(
private RoleMapper $roleMapper,
private UserRoleMapper $userRoleMapper,
private UserRoleService $userRoleService,
private IUserManager $userManager,
) {
parent::__construct();
@@ -70,22 +69,19 @@ class SetRole extends Command {
}
// Check if user already has this role
$userRoles = $this->userRoleMapper->findByUserId($username);
foreach ($userRoles as $userRole) {
if ($userRole->getRoleId() === $role->getId()) {
$output->writeln("<comment>User '$username' already has the role '{$role->getName()}'.</comment>");
return 0;
}
if ($this->userRoleService->hasRole($username, $role->getId())) {
$output->writeln("<comment>User '$username' already has the role '{$role->getName()}'.</comment>");
return 0;
}
// Add the role to the user
$userRole = new UserRole();
$userRole->setUserId($username);
$userRole->setRoleId($role->getId());
$userRole->setCreatedAt(time());
$this->userRoleMapper->insert($userRole);
$output->writeln("<info>Successfully assigned role '{$role->getName()}' to user '$username'.</info>");
return 0;
// Add the role to the user using the service
try {
$this->userRoleService->assignRole($username, $role->getId(), skipIfExists: false);
$output->writeln("<info>Successfully assigned role '{$role->getName()}' to user '$username'.</info>");
return 0;
} catch (\Exception $ex) {
$output->writeln("<error>Failed to assign role '{$role->getName()}' to user '$username': {$ex->getMessage()}</error>");
return 1;
}
}
}

View File

@@ -14,6 +14,7 @@ use OCA\Forum\Db\CategoryPermMapper;
use OCA\Forum\Db\CatHeaderMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\UserRoleMapper;
use OCA\Forum\Service\UserRoleService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -384,7 +385,8 @@ class CategoryController extends OCSController {
#[ApiRoute(verb: 'GET', url: '/api/categories/{id}/permissions')]
public function getPermissions(int $id): DataResponse {
try {
$permissions = $this->categoryPermMapper->findByCategoryId($id);
// Exclude Admin role - it has hardcoded full access to all categories
$permissions = $this->categoryPermMapper->findByCategoryIdExcludingAdmin($id);
return new DataResponse(array_map(fn ($perm) => $perm->jsonSerialize(), $permissions));
} catch (\Exception $e) {
$this->logger->error('Error fetching category permissions: ' . $e->getMessage());
@@ -412,8 +414,13 @@ class CategoryController extends OCSController {
// Delete existing permissions for this category
$this->categoryPermMapper->deleteByCategoryId($id);
// Filter out Admin role - it has hardcoded full access
$filteredPermissions = array_filter($permissions, fn ($perm)
=> ($perm['roleId'] ?? null) !== UserRoleService::ROLE_ADMIN
);
// Insert new permissions
foreach ($permissions as $perm) {
foreach ($filteredPermissions as $perm) {
$categoryPerm = new CategoryPerm();
$categoryPerm->setCategoryId($id);
$categoryPerm->setRoleId($perm['roleId']);

View File

@@ -383,7 +383,10 @@ class PostController extends OCSController {
// Update thread post count and lastPostId
try {
$thread = $this->threadMapper->find($post->getThreadId());
$thread->setPostCount(max(0, $thread->getPostCount() - 1));
// Only decrement post count for reply posts (not first posts)
if (!$post->getIsFirstPost()) {
$thread->setPostCount(max(0, $thread->getPostCount() - 1));
}
$thread->setUpdatedAt(time());
// If the deleted post was the last post, update lastPostId to the previous non-deleted post
@@ -406,22 +409,25 @@ class PostController extends OCSController {
// Update user stats - decrement post count, and thread count if it's the first post
try {
$this->userStatsMapper->decrementPostCount($post->getAuthorId());
// If this is the first post of a thread, also decrement thread count
if ($post->getIsFirstPost()) {
// First post: decrement thread count only
$this->userStatsMapper->decrementThreadCount($post->getAuthorId());
} else {
// Reply post: decrement post count only
$this->userStatsMapper->decrementPostCount($post->getAuthorId());
}
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats after post deletion: ' . $e->getMessage());
// Don't fail the request if stats update fails
}
// Update category post count
// Update category post count (only for reply posts, not first posts)
try {
$category = $this->categoryMapper->find($categoryId);
$category->setPostCount(max(0, $category->getPostCount() - 1));
$this->categoryMapper->update($category);
if (!$post->getIsFirstPost()) {
$category = $this->categoryMapper->find($categoryId);
$category->setPostCount(max(0, $category->getPostCount() - 1));
$this->categoryMapper->update($category);
}
} catch (\Exception $e) {
$this->logger->warning('Failed to update category post count after post deletion: ' . $e->getMessage());
// Don't fail the request if category update fails

View File

@@ -11,6 +11,7 @@ use OCA\Forum\Attribute\RequirePermission;
use OCA\Forum\Db\CategoryPerm;
use OCA\Forum\Db\CategoryPermMapper;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Service\UserRoleService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -163,14 +164,22 @@ class RoleController extends OCSController {
if ($colorDark !== null) {
$role->setColorDark($colorDark);
}
if ($canAccessAdminTools !== null) {
$role->setCanAccessAdminTools($canAccessAdminTools);
}
if ($canEditRoles !== null) {
$role->setCanEditRoles($canEditRoles);
}
if ($canEditCategories !== null) {
$role->setCanEditCategories($canEditCategories);
// Admin role always has all permissions - cannot be changed
if ($id === UserRoleService::ROLE_ADMIN) {
$role->setCanAccessAdminTools(true);
$role->setCanEditRoles(true);
$role->setCanEditCategories(true);
} else {
if ($canAccessAdminTools !== null) {
$role->setCanAccessAdminTools($canAccessAdminTools);
}
if ($canEditRoles !== null) {
$role->setCanEditRoles($canEditRoles);
}
if ($canEditCategories !== null) {
$role->setCanEditCategories($canEditCategories);
}
}
/** @var \OCA\Forum\Db\Role */
@@ -188,17 +197,22 @@ class RoleController extends OCSController {
* Delete a role
*
* @param int $id Role ID
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{error: string}, array{}>
*
* 200: Role deleted
* 403: Cannot delete system roles
*/
#[NoAdminRequired]
#[RequirePermission('canEditRoles')]
#[ApiRoute(verb: 'DELETE', url: '/api/roles/{id}')]
public function destroy(int $id): DataResponse {
try {
// Prevent deleting system roles (Admin, Moderator, Member)
if ($id == 3) {
// Prevent deleting system roles (Admin, Moderator, User)
if (in_array($id, [
UserRoleService::ROLE_ADMIN,
UserRoleService::ROLE_MODERATOR,
UserRoleService::ROLE_USER,
], true)) {
return new DataResponse(['error' => 'System roles cannot be deleted'], Http::STATUS_FORBIDDEN);
}

View File

@@ -257,23 +257,22 @@ class ThreadController extends OCSController {
$createdPost = $this->postMapper->insert($post);
// Update thread with post count and last post
$createdThread->setPostCount(1);
// Note: post_count does NOT include the first post (is_first_post=true)
$createdThread->setPostCount(0);
$createdThread->setLastPostId($createdPost->getId());
$this->threadMapper->update($createdThread);
// Update category counts (thread count and post count)
// Update category counts (thread count only, first post doesn't count)
try {
$category = $this->categoryMapper->find($categoryId);
$category->setThreadCount($category->getThreadCount() + 1);
$category->setPostCount($category->getPostCount() + 1);
$this->categoryMapper->update($category);
} catch (\Exception $e) {
$this->logger->warning('Failed to update category counts: ' . $e->getMessage());
}
// Update user stats (post count and thread count, auto-creates stats if needed)
// Update user stats (thread count only, first post doesn't count)
try {
$this->userStatsMapper->incrementPostCount($user->getUID());
$this->userStatsMapper->incrementThreadCount($user->getUID());
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats: ' . $e->getMessage());

View File

@@ -9,6 +9,7 @@ namespace OCA\Forum\Controller;
use OCA\Forum\Attribute\RequirePermission;
use OCA\Forum\Db\UserRoleMapper;
use OCA\Forum\Service\UserRoleService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -23,6 +24,7 @@ class UserRoleController extends OCSController {
string $appName,
IRequest $request,
private UserRoleMapper $userRoleMapper,
private UserRoleService $userRoleService,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
@@ -75,22 +77,29 @@ class UserRoleController extends OCSController {
*
* @param string $userId Nextcloud user ID
* @param int $roleId Role ID
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>
* @return DataResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>|DataResponse<Http::STATUS_CONFLICT, array{error: string}, array{}>
*
* 201: Role assigned to user
* 409: User already has this role
*/
#[NoAdminRequired]
#[RequirePermission('canEditRoles')]
#[ApiRoute(verb: 'POST', url: '/api/user-roles')]
public function create(string $userId, int $roleId): DataResponse {
try {
$userRole = new \OCA\Forum\Db\UserRole();
$userRole->setUserId($userId);
$userRole->setRoleId($roleId);
$userRole->setCreatedAt(time());
// Check if user already has the role
if ($this->userRoleService->hasRole($userId, $roleId)) {
return new DataResponse(['error' => 'User already has this role'], Http::STATUS_CONFLICT);
}
// Assign the role using the service
$createdUserRole = $this->userRoleService->assignRole($userId, $roleId, skipIfExists: false);
if ($createdUserRole === null) {
// This shouldn't happen since we checked hasRole above, but handle it just in case
return new DataResponse(['error' => 'Failed to assign role'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
/** @var \OCA\Forum\Db\UserRole */
$createdUserRole = $this->userRoleMapper->insert($userRole);
return new DataResponse($createdUserRole->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error assigning role to user: ' . $e->getMessage());

View File

@@ -8,6 +8,7 @@ declare(strict_types=1);
namespace OCA\Forum\Db;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Service\UserRoleService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
@@ -67,6 +68,26 @@ class CategoryPermMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Find permissions for a category, excluding Admin role (which has implicit full access)
*
* @param int $categoryId Category ID
* @return array<CategoryPerm>
*/
public function findByCategoryIdExcludingAdmin(int $categoryId): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('category_id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT))
)
->andWhere(
$qb->expr()->neq('role_id', $qb->createNamedParameter(UserRoleService::ROLE_ADMIN, IQueryBuilder::PARAM_INT))
);
return $this->findEntities($qb);
}
/**
* Find permission for specific category and role
*

View File

@@ -165,6 +165,9 @@ class PostMapper extends QBMapper {
)
->andWhere(
$qb->expr()->isNull('t.deleted_at')
)
->andWhere(
$qb->expr()->eq('p.is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
);
$result = $qb->executeQuery();
$row = $result->fetch();

View File

@@ -9,6 +9,7 @@ namespace OCA\Forum\Listener;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\StatsService;
use OCA\Forum\Service\UserRoleService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\User\Events\UserCreatedEvent;
@@ -22,6 +23,7 @@ class UserEventListener implements IEventListener {
public function __construct(
private UserStatsMapper $userStatsMapper,
private StatsService $statsService,
private UserRoleService $userRoleService,
private LoggerInterface $logger,
) {
}
@@ -47,6 +49,16 @@ class UserEventListener implements IEventListener {
'exception' => $ex->getMessage(),
]);
}
try {
// Assign default user role to new user
$this->userRoleService->assignRole($userId, UserRoleService::ROLE_USER, skipIfExists: true);
$this->logger->info("Assigned default user role to new Nextcloud user: {$userId}");
} catch (\Exception $ex) {
$this->logger->error("Failed to assign default user role to new user: {$userId}", [
'exception' => $ex->getMessage(),
]);
}
}
private function handleUserDeleted(UserDeletedEvent $event): void {

View File

@@ -258,7 +258,7 @@ class SeedHelper {
$qb->insert('forum_categories')
->values([
'header_id' => $qb->createNamedParameter($headerId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'name' => $qb->createNamedParameter($l->t('General Discussions')),
'name' => $qb->createNamedParameter($l->t('General discussions')),
'description' => $qb->createNamedParameter($l->t('A place for general conversations and discussions')),
'slug' => $qb->createNamedParameter('general-discussions'),
'sort_order' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
@@ -472,7 +472,7 @@ class SeedHelper {
[
'tag' => 'icode',
'replacement' => '<code>{content}</code>',
'example' => '[icode]inline code[/icode]',
'example' => $l->t('[icode]inline code[/icode]'),
'description' => $l->t('Inline code'),
'parse_inner' => false,
'is_builtin' => true,
@@ -481,7 +481,7 @@ class SeedHelper {
[
'tag' => 'spoiler',
'replacement' => '<details><summary>{title}</summary>{content}</details>',
'example' => '[spoiler="Spoiler Title"]Hidden content[/spoiler]',
'example' => $l->t('[spoiler="%1$s"]%2$s[/spoiler]', ['Spoiler Title', 'Hidden content']),
'description' => $l->t('Spoilers'),
'parse_inner' => false,
'is_builtin' => true,
@@ -489,8 +489,8 @@ class SeedHelper {
],
[
'tag' => 'attachment',
'replacement' => '[attachment]/file/path.txt[/attachment]',
'example' => '',
'replacement' => '{content}',
'example' => '[attachment]/file/path.txt[/attachment]',
'description' => $l->t('Attachment'),
'parse_inner' => false,
'is_builtin' => true,
@@ -719,7 +719,7 @@ class SeedHelper {
'title' => $qb->createNamedParameter($l->t('Welcome to Nextcloud Forums')),
'slug' => $qb->createNamedParameter('welcome-to-nextcloud-forums'),
'view_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'post_count' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'last_post_id' => $qb->createNamedParameter(null, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'is_locked' => $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
'is_pinned' => $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL),
@@ -745,9 +745,9 @@ class SeedHelper {
. "[/list]\n"
. '[b]' . $l->t('BBCode Examples:') . "[/b]\n"
. "[list]\n"
. '[*][b]' . $l->t('Bold text') . '[/b] - ' . $l->t('Use {codeStart}text{codeEnd}', ['{codeStart}' => '[icode][b]', '{codeEnd}' => '[/b][/icode]']) . "\n"
. '[*][i]' . $l->t('Italic text') . '[/i] - ' . $l->t('Use {codeStart}text{codeEnd}', ['{codeStart}' => '[icode][i]', '{codeEnd}' => '[/i][/icode]']) . "\n"
. '[*][u]' . $l->t('Underlined text') . '[/u] - ' . $l->t('Use {codeStart}text{codeEnd}', ['{codeStart}' => '[icode][u]', '{codeEnd}' => '[/u][/icode]']) . "\n\n"
. '[*][b]' . $l->t('Bold text') . '[/b] - ' . $l->t('Use %1$stext%2$s', ['[icode][b]', '[/b][/icode]']) . "\n"
. '[*][i]' . $l->t('Italic text') . '[/i] - ' . $l->t('Use %1$stext%2$s', ['[icode][i]', '[/i][/icode]']) . "\n"
. '[*][u]' . $l->t('Underlined text') . '[/u] - ' . $l->t('Use %1$stext%2$s', ['[icode][u]', '[/u][/icode]']) . "\n\n"
. "[/list]\n"
. $l->t('Feel free to start a new discussion or reply to existing threads. Happy posting!');
@@ -783,6 +783,18 @@ class SeedHelper {
->where($qb->expr()->eq('id', $qb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->executeStatement();
// Subscribe the admin user to the welcome thread
if ($db->tableExists('forum_thread_subs')) {
$qb = $db->getQueryBuilder();
$qb->insert('forum_thread_subs')
->values([
'thread_id' => $qb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'user_id' => $qb->createNamedParameter($adminUserId),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
}
// Create user stats for the admin user
$qb = $db->getQueryBuilder();
$qb->insert('forum_user_stats')

View File

@@ -114,6 +114,62 @@ class Version2Date20251114222614 extends SimpleMigrationStep {
$output->info(sprintf('Created %d new user stats', $result['created']));
$output->info(sprintf('Updated %d existing user stats', $result['updated']));
$output->info('User statistics created successfully!');
// Subscribe thread authors to their threads
$this->subscribeAuthorsToThreads($output);
}
/**
* Subscribe all thread authors to their threads
*/
private function subscribeAuthorsToThreads(IOutput $output): void {
$output->info('Subscribing thread authors to their threads...');
$db = \OC::$server->get(\OCP\IDBConnection::class);
$timestamp = time();
$subscribed = 0;
try {
// Get all threads with their authors
$qb = $db->getQueryBuilder();
$qb->select('id', 'author_id')
->from('forum_threads');
$result = $qb->executeQuery();
$threads = $result->fetchAll();
$result->closeCursor();
foreach ($threads as $thread) {
$threadId = (int)$thread['id'];
$authorId = $thread['author_id'];
// Check if author is already subscribed
$qb = $db->getQueryBuilder();
$qb->select('id')
->from('forum_thread_subs')
->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('user_id', $qb->createNamedParameter($authorId)));
$result = $qb->executeQuery();
$exists = $result->fetch();
$result->closeCursor();
if (!$exists) {
// Subscribe the author to their thread
$qb = $db->getQueryBuilder();
$qb->insert('forum_thread_subs')
->values([
'thread_id' => $qb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'user_id' => $qb->createNamedParameter($authorId),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
])
->executeStatement();
$subscribed++;
}
}
$output->info(sprintf('Subscribed %d thread authors to their threads', $subscribed));
} catch (\Exception $e) {
$output->warning('Failed to subscribe thread authors: ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Migration;
use Closure;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Service\UserRoleService;
use OCP\DB\ISchemaWrapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version6Date20251122233018 extends SimpleMigrationStep {
public function __construct(
private IDBConnection $db,
) {
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
// TODO add migration logic
return $schema;
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
// Remove Admin role permissions from categories
// Admin role now has hardcoded full access to all categories
$qb = $this->db->getQueryBuilder();
$qb->delete(Application::tableName('forum_category_perms'))
->where(
$qb->expr()->eq('role_id', $qb->createNamedParameter(UserRoleService::ROLE_ADMIN, IQueryBuilder::PARAM_INT))
);
$deletedCount = $qb->executeStatement();
$output->info("Removed $deletedCount Admin role permission entries from categories (Admin has hardcoded full access)");
}
}

View File

@@ -58,7 +58,7 @@ class Notifier implements INotifier {
// Set the rich subject with thread title
$notification->setRichSubject(
$l->n(
'New reply in {thread}',
'{count} new reply in {thread}',
'{count} new replies in {thread}',
$postCount
),

View File

@@ -28,11 +28,34 @@ class PermissionService {
) {
}
/**
* Check if user has Admin role
*
* @param string $userId Nextcloud user ID
* @return bool True if user has Admin role
*/
private function hasAdminRole(string $userId): bool {
try {
$userRoles = $this->userRoleMapper->findByUserId($userId);
foreach ($userRoles as $userRole) {
if ($userRole->getRoleId() === UserRoleService::ROLE_ADMIN) {
return true;
}
}
return false;
} catch (\Exception $e) {
$this->logger->error("Error checking admin role for user $userId: " . $e->getMessage());
return false;
}
}
/**
* Check if user has Admin or Moderator role
*
* @param string $userId Nextcloud user ID
* @return bool True if user has Admin (roleId 1) or Moderator (roleId 2) role
* @return bool True if user has Admin or Moderator role
*/
public function hasAdminOrModeratorRole(string $userId): bool {
try {
@@ -40,8 +63,7 @@ class PermissionService {
foreach ($userRoles as $userRole) {
$roleId = $userRole->getRoleId();
// Admin role = 1, Moderator role = 2
if ($roleId === 1 || $roleId === 2) {
if ($roleId === UserRoleService::ROLE_ADMIN || $roleId === UserRoleService::ROLE_MODERATOR) {
return true;
}
}
@@ -125,6 +147,12 @@ class PermissionService {
* @return bool True if user has the permission
*/
public function hasCategoryPermission(string $userId, int $categoryId, string $permission): bool {
// Admin role has hardcoded full access to all categories
if ($this->hasAdminRole($userId)) {
$this->logger->debug("User $userId has Admin role - granting category permission '$permission' on category $categoryId");
return true;
}
try {
$userRoles = $this->userRoleMapper->findByUserId($userId);

View File

@@ -207,14 +207,15 @@ class StatsService {
$threadCount = (int)($threadResult->fetchOne() ?? 0);
$threadResult->closeCursor();
// Count non-deleted posts in non-deleted threads in this category
// Count non-deleted posts in non-deleted threads in this category (excluding first posts)
$postQb = $this->db->getQueryBuilder();
$postQb->select($postQb->func()->count('*', 'count'))
->from('forum_posts', 'p')
->innerJoin('p', 'forum_threads', 't', $postQb->expr()->eq('p.thread_id', 't.id'))
->where($postQb->expr()->eq('t.category_id', $postQb->createNamedParameter($categoryId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->andWhere($postQb->expr()->isNull('p.deleted_at'))
->andWhere($postQb->expr()->isNull('t.deleted_at'));
->andWhere($postQb->expr()->isNull('t.deleted_at'))
->andWhere($postQb->expr()->eq('p.is_first_post', $postQb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)));
$postResult = $postQb->executeQuery();
$postCount = (int)($postResult->fetchOne() ?? 0);
$postResult->closeCursor();
@@ -266,12 +267,13 @@ class StatsService {
* @return void
*/
public function rebuildThreadStats(int $threadId): void {
// Count non-deleted posts in this thread
// Count non-deleted posts in this thread (excluding first post)
$postQb = $this->db->getQueryBuilder();
$postQb->select($postQb->func()->count('*', 'count'))
->from('forum_posts')
->where($postQb->expr()->eq('thread_id', $postQb->createNamedParameter($threadId, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->andWhere($postQb->expr()->isNull('deleted_at'));
->andWhere($postQb->expr()->isNull('deleted_at'))
->andWhere($postQb->expr()->eq('is_first_post', $postQb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)));
$postResult = $postQb->executeQuery();
$postCount = (int)($postResult->fetchOne() ?? 0);
$postResult->closeCursor();

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Service;
use OCA\Forum\Db\UserRole;
use OCA\Forum\Db\UserRoleMapper;
use Psr\Log\LoggerInterface;
/**
* Service for managing user role assignments
*/
class UserRoleService {
/** @var int Admin role ID */
public const ROLE_ADMIN = 1;
/** @var int Moderator role ID */
public const ROLE_MODERATOR = 2;
/** @var int User role ID */
public const ROLE_USER = 3;
public function __construct(
private UserRoleMapper $userRoleMapper,
private LoggerInterface $logger,
) {
}
/**
* Assign a role to a user
*
* @param string $userId The user ID
* @param int $roleId The role ID to assign
* @param bool $skipIfExists If true, silently skip if user already has the role. If false, log a warning.
* @return UserRole|null The created UserRole, or null if already exists and skipIfExists is true
*/
public function assignRole(string $userId, int $roleId, bool $skipIfExists = true): ?UserRole {
// Check if user already has this role
if ($this->hasRole($userId, $roleId)) {
if ($skipIfExists) {
return null;
}
$this->logger->warning('User {userId} already has role {roleId}', [
'userId' => $userId,
'roleId' => $roleId,
]);
return null;
}
// Create and insert the new user role
$userRole = new UserRole();
$userRole->setUserId($userId);
$userRole->setRoleId($roleId);
$userRole->setCreatedAt(time());
try {
$createdRole = $this->userRoleMapper->insert($userRole);
$this->logger->info('Assigned role {roleId} to user {userId}', [
'userId' => $userId,
'roleId' => $roleId,
]);
return $createdRole;
} catch (\Exception $ex) {
$this->logger->error('Failed to assign role {roleId} to user {userId}: {error}', [
'userId' => $userId,
'roleId' => $roleId,
'error' => $ex->getMessage(),
]);
throw $ex;
}
}
/**
* Check if a user has a specific role
*
* @param string $userId The user ID
* @param int $roleId The role ID to check
* @return bool True if user has the role, false otherwise
*/
public function hasRole(string $userId, int $roleId): bool {
$userRoles = $this->userRoleMapper->findByUserId($userId);
foreach ($userRoles as $userRole) {
if ($userRole->getRoleId() === $roleId) {
return true;
}
}
return false;
}
/**
* Remove a role from a user
*
* @param string $userId The user ID
* @param int $roleId The role ID to remove
* @return bool True if role was removed, false if user didn't have the role
*/
public function removeRole(string $userId, int $roleId): bool {
$userRoles = $this->userRoleMapper->findByUserId($userId);
foreach ($userRoles as $userRole) {
if ($userRole->getRoleId() === $roleId) {
try {
$this->userRoleMapper->delete($userRole);
$this->logger->info('Removed role {roleId} from user {userId}', [
'userId' => $userId,
'roleId' => $roleId,
]);
return true;
} catch (\Exception $ex) {
$this->logger->error('Failed to remove role {roleId} from user {userId}: {error}', [
'userId' => $userId,
'roleId' => $roleId,
'error' => $ex->getMessage(),
]);
throw $ex;
}
}
}
$this->logger->debug('User {userId} does not have role {roleId}, nothing to remove', [
'userId' => $userId,
'roleId' => $roleId,
]);
return false;
}
/**
* Get all role IDs for a user
*
* @param string $userId The user ID
* @return array<int> Array of role IDs
*/
public function getUserRoleIds(string $userId): array {
$userRoles = $this->userRoleMapper->findByUserId($userId);
return array_map(fn ($userRole) => $userRole->getRoleId(), $userRoles);
}
}

View File

@@ -6195,6 +6195,44 @@
}
}
},
"403": {
"description": "Cannot delete system roles",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
@@ -8932,6 +8970,44 @@
}
}
},
"409": {
"description": "User already has this role",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {

View File

@@ -1,466 +0,0 @@
<template>
<div id="forum-content" class="section">
<h2>{{ strings.title }}</h2>
<!-- Information / quick start -->
<NcAppSettingsSection :name="strings.infoTitle">
<p v-html="strings.infoIntro"></p>
<ol class="ol">
<li v-for="li in strings.gettingStartedList" :key="li" v-html="li"></li>
</ol>
<NcNoteCard type="info">
<p v-html="strings.tipsNote"></p>
</NcNoteCard>
</NcAppSettingsSection>
<!-- Live examples -->
<NcAppSettingsSection :name="strings.examplesHeader">
<section class="example-grid">
<!-- v-model example -->
<div class="card">
<h3 class="card-title">{{ strings.nameInputHeader }}</h3>
<NcTextField
v-model="name"
:label="strings.nameInputLabel"
:placeholder="strings.nameInputPlaceholder"
/>
<p class="mt-8">
{{ strings.livePreview }} <b>{{ greeting }}</b>
</p>
</div>
<!-- Select + computed example -->
<div class="card">
<h3 class="card-title">{{ strings.themeHeader }}</h3>
<NcSelect
v-model="themeLabel"
:options="themeOptionsLabels"
:input-label="strings.themeLabel"
/>
<p class="mt-8">
{{ strings.themePreview }}
<code>{{ activeTheme.value }}</code>
</p>
</div>
<!-- Counter + events example -->
<div class="card">
<h3 class="card-title">{{ strings.counterHeader }}</h3>
<div class="row gap-8">
<NcButton @click="decrement">{{ strings.minus }}</NcButton>
<span class="counter">{{ counter }}</span>
<NcButton @click="increment">{{ strings.plus }}</NcButton>
</div>
</div>
</section>
</NcAppSettingsSection>
<!-- Table + add/remove items example -->
<NcAppSettingsSection :name="strings.itemsHeader">
<div class="row align-start gap-16">
<div style="max-width: 320px">
<NcTextField
v-model="newItem"
:label="strings.newItemLabel"
:placeholder="strings.newItemPlaceholder"
trailing-button-icon="plus"
:show-trailing-button="newItem.trim() !== ''"
@trailing-button-click="addItem"
/>
</div>
<NcButton @click="addItem" :disabled="newItem.trim() === ''">{{ strings.add }}</NcButton>
<NcButton type="secondary" @click="clearItems" :disabled="items.length === 0">
{{ strings.clear }}
</NcButton>
</div>
<table class="mt-16">
<thead>
<tr>
<th style="width: 60%">{{ strings.tableItem }}</th>
<th style="width: 40%">{{ strings.tableActions }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in items" :key="item.id">
<td>
<input class="inline-input" :aria-label="strings.editItemAria" v-model="item.label" />
</td>
<td>
<div class="row gap-8">
<NcButton type="tertiary" @click="duplicate(idx)">{{ strings.duplicate }}</NcButton>
<NcButton type="error" @click="remove(idx)">{{ strings.remove }}</NcButton>
</div>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="2" class="muted">{{ strings.noItems }}</td>
</tr>
</tbody>
</table>
</NcAppSettingsSection>
<!-- Backend calls example -->
<NcAppSettingsSection :name="strings.backendHeader">
<form @submit.prevent @submit="save">
<div class="row gap-16 align-center">
<NcButton @click="fetchHello" :disabled="loading">{{ strings.fetchHello }}</NcButton>
<NcButton :disabled="loading" @click="submit">{{ strings.save }}</NcButton>
<span>
<span v-if="loading">{{ strings.loading }}</span>
<span v-else-if="lastHelloAt">
{{ strings.lastHelloAt }}
<NcDateTime :timestamp="lastHelloAt.valueOf()" />
</span>
<span v-else class="muted">{{ strings.never }}</span>
</span>
</div>
</form>
<NcNoteCard v-if="serverMessage" type="success" class="mt-12">
<p>
{{ strings.serverSaid }} <code>{{ serverMessage }}</code>
</p>
</NcNoteCard>
</NcAppSettingsSection>
</div>
</template>
<script>
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
export default {
name: 'HelloWorld',
components: {
NcAppSettingsSection,
NcButton,
NcDateTime,
NcNoteCard,
NcSelect,
NcTextField,
},
data() {
return {
// UI state
loading: false,
// Example: simple input
name: '',
// Example: select with label <-> value mapping (like your intervals)
themeLabel: null,
themeOptions: [
{ label: t('forum', 'Light'), value: 'light' },
{ label: t('forum', 'Dark'), value: 'dark' },
{
label: n('forum', 'System (1 option)', 'System (%n options)', 2),
value: 'system',
},
],
// Example: small counter
counter: 0,
// Example: simple items table
items: [],
newItem: '',
// Example: tracking server interactions
lastHelloAt: null,
serverMessage: '',
// All user-visible strings go here
strings: {
// Titles / headers
title: t('forum', 'Hello World — App Template'),
infoTitle: t('forum', 'Information'),
examplesHeader: t('forum', 'Quick Examples'),
itemsHeader: t('forum', 'Editable List'),
backendHeader: t('forum', 'Backend Calls'),
// Info
infoIntro: t(
'forum',
'This view shows {bStart}small, focused examples{bEnd} for inputs, lists, selections, and backend calls.',
{ bStart: '<b>', bEnd: '</b>' },
undefined,
{ escape: false },
),
gettingStartedList: [
t(
'forum',
'Import UI parts from {cStart}@nextcloud/vue{cEnd} and wire them with {cStart}v-model{cEnd}.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
t(
'forum',
'Use {cStart}axios{cEnd} for API calls; return OCS data as needed.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
t(
'forum',
'Keep user-facing text in a central {cStart}strings{cEnd} object with {cStart}t/n{cEnd}.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
],
tipsNote: t(
'forum',
'Pro tip: keep labels in {cStart}label{cEnd} and values in {cStart}value{cEnd} to simplify mapping.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
// Name example
nameInputHeader: t('forum', 'Chen Asraf'),
nameInputLabel: t('forum', 'Name'),
nameInputPlaceholder: t('forum', 'e.g. Ada Lovelace'),
livePreview: t('forum', 'Live preview:'),
// Theme example
themeHeader: t('forum', 'Theme'),
themeLabel: t('forum', 'Choose a theme'),
themePreview: t('forum', 'Active value:'),
// Counter example
counterHeader: t('forum', 'Counter'),
plus: t('forum', '+1'),
minus: t('forum', '-1'),
// Items table
newItemLabel: t('forum', 'New item'),
newItemPlaceholder: t('forum', 'e.g. Hello item'),
add: t('forum', 'Add'),
clear: t('forum', 'Clear'),
tableItem: t('forum', 'Item'),
tableActions: t('forum', 'Actions'),
editItemAria: t('forum', 'Edit item'),
duplicate: t('forum', 'Duplicate'),
remove: t('forum', 'Remove'),
noItems: t('forum', 'No items yet'),
// Backend
fetchHello: t('forum', 'Fetch Hello'),
save: t('forum', 'Save'),
loading: t('forum', 'Loading…'),
lastHelloAt: t('forum', 'Last hello at:'),
never: t('forum', 'Never'),
serverSaid: t('forum', 'Server said:'),
},
}
},
created() {
// Load initial data if you want
this.fetchHello()
},
computed: {
// Map selected theme label -> full option
activeTheme() {
return this.themeOptions.find((x) => x.label === this.themeLabel) ?? this.themeOptions[0]
},
// Convenience list for NcSelect (labels only)
themeOptionsLabels() {
return this.themeOptions.map((x) => x.label)
},
// Live greeting preview (reacts to "name")
greeting() {
return this.name.trim() ? `Hello, ${this.name.trim()}!` : 'Hello!'
},
},
methods: {
// Counter handlers
increment() {
this.counter++
},
decrement() {
this.counter--
},
// Items handlers
addItem() {
const label = this.newItem.trim()
if (!label) return
this.items.push({ id: cryptoRandom(), label })
this.newItem = ''
},
duplicate(index) {
const src = this.items[index]
if (!src) return
this.items.splice(index + 1, 0, { id: cryptoRandom(), label: src.label })
},
remove(index) {
this.items.splice(index, 1)
},
clearItems() {
this.items = []
},
// Backend examples (adjust endpoints to your apps routes)
async fetchHello() {
try {
this.loading = true
// Example GET -> /hello (expects: { ocs: { data: { message: string, at: string }}})
const resp = await ocs.get('/hello')
this.serverMessage = resp.data.message ?? '👋'
// If backend returns ISO date strings, store a Date instance
if (resp.data.at) this.lastHelloAt = new Date(resp.data.at)
} catch (e) {
console.error('Failed to fetch hello', e)
} finally {
this.loading = false
}
},
async save() {
try {
this.loading = true
// Example POST -> /hello (send minimal payload)
const payload = {
name: this.name.trim() || null,
theme: this.activeTheme.value,
items: this.items.map((x) => x.label),
counter: this.counter,
}
const resp = await ocs.post('/hello', { data: payload })
// Update preview/message
if (resp.data.message) this.serverMessage = resp.data.message
if (resp.data.at) this.lastHelloAt = new Date(resp.data.at)
} catch (e) {
console.error('Failed to save hello', e)
} finally {
this.loading = false
}
},
},
}
/** Small helper for local IDs (no crypto dep) */
function cryptoRandom() {
return Math.random().toString(36).slice(2, 10)
}
</script>
<style scoped lang="scss">
#forum-content {
h2:first-child {
margin-top: 0;
}
.mt-8 {
margin-top: 8px;
}
.mt-12 {
margin-top: 12px;
}
.mt-16 {
margin-top: 16px;
}
.row {
display: flex;
&.align-start {
align-items: flex-start;
}
&.align-center {
align-items: center;
}
&.gap-8 {
gap: 8px;
}
&.gap-16 {
gap: 16px;
}
}
.example-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.card {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 12px;
}
.card-title {
margin: 0 0 8px 0;
font-size: 1rem;
font-weight: 600;
}
.counter {
min-width: 3ch;
text-align: center;
font-variant-numeric: tabular-nums;
}
.inline-input {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-main-background);
color: var(--color-main-text);
}
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
}
.ol {
padding-left: 2.5em;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border);
margin-top: 8px;
tr:not(:last-child),
thead tr {
border-bottom: 1px solid var(--color-border);
}
thead,
tbody tr {
display: table;
width: 100%;
table-layout: fixed;
}
td,
th {
padding: 6px 8px;
vertical-align: middle;
}
}
}
</style>

View File

@@ -242,10 +242,10 @@ export default defineComponent({
searchLabel: t('forum', 'Search'),
navHome: t('forum', 'Home'),
navSearch: t('forum', 'Search'),
navPreferences: t('forum', 'User Preferences'),
navPreferences: t('forum', 'User preferences'),
navAdmin: t('forum', 'Admin'),
navAdminDashboard: t('forum', 'Dashboard'),
navAdminSettings: t('forum', 'Forum Settings'),
navAdminSettings: t('forum', 'Forum settings'),
navAdminUsers: t('forum', 'Users'),
navAdminRoles: t('forum', 'Roles'),
navAdminCategories: t('forum', 'Categories'),

View File

@@ -111,73 +111,156 @@ export default defineComponent({
builtinDbCodes: [] as BBCode[],
builtInCodes: [
{ tag: 'b', name: t('forum', 'Font style bold'), example: '[b]Hello world[/b]' },
{ tag: 'i', name: t('forum', 'Font style italic'), example: '[i]Hello world[/i]' },
{ tag: 's', name: t('forum', 'Font style struck through'), example: '[s]Hello world[/s]' },
{ tag: 'u', name: t('forum', 'Font style underlined'), example: '[u]Hello world[/u]' },
{ tag: 'code', name: t('forum', 'Code'), example: '[code]Hello world[/code]' },
{
tag: 'b',
name: t('forum', 'Font style bold'),
example: t('forum', '{bStart}Hello world{bEnd}', { bStart: '[b]', bEnd: '[/b]' }),
},
{
tag: 'i',
name: t('forum', 'Font style italic'),
example: t('forum', '{iStart}Hello world{iEnd}', { iStart: '[i]', iEnd: '[/i]' }),
},
{
tag: 's',
name: t('forum', 'Font style struck through'),
example: t('forum', '{sStart}Hello world{sEnd}', { sStart: '[s]', sEnd: '[/s]' }),
},
{
tag: 'u',
name: t('forum', 'Font style underlined'),
example: t('forum', '{uStart}Hello world{uEnd}', { uStart: '[u]', uEnd: '[/u]' }),
},
{
tag: 'code',
name: t('forum', 'Code'),
example: t('forum', '{codeStart}Hello world{codeEnd}', {
codeStart: '[code]',
codeEnd: '[/code]',
}),
},
{
tag: 'email',
name: t('forum', 'Email (clickable)'),
example: '[email]test@example.com[/email]',
example: t('forum', '{emailStart}test@example.com{emailEnd}', {
emailStart: '[email]',
emailEnd: '[/email]',
}),
},
{
tag: 'url',
name: t('forum', 'URL (clickable)'),
example: '[url=http://example.com]Example.com[/url]',
example: t('forum', '{urlStart}Example.com{urlEnd}', {
urlStart: '[url=http://example.com]',
urlEnd: '[/url]',
}),
},
{
tag: 'img',
name: t('forum', 'Image (not clickable)'),
example: '[img]http://example.com/example.png[/img]',
example: t('forum', '{imgStart}http://example.com/example.png{imgEnd}', {
imgStart: '[img]',
imgEnd: '[/img]',
}),
},
{
tag: 'quote',
name: t('forum', 'Quote'),
example: t('forum', '{quoteStart}Hello world{quoteEnd}', {
quoteStart: '[quote]',
quoteEnd: '[/quote]',
}),
},
{ tag: 'quote', name: t('forum', 'Quote'), example: '[quote]Hello world[/quote]' },
{
tag: 'youtube',
name: t('forum', 'Embedded YouTube video'),
example: '[youtube]a-video-id-123456[/youtube]',
example: t('forum', '{youtubeStart}a-video-id-123456{youtubeEnd}', {
youtubeStart: '[youtube]',
youtubeEnd: '[/youtube]',
}),
},
{
tag: 'font',
name: t('forum', 'Font (name)'),
example: '[font=Arial]Hello world![/font]',
example: t('forum', '{fontStart}Hello world!{fontEnd}', {
fontStart: '[font=Arial]',
fontEnd: '[/font]',
}),
},
{
tag: 'size',
name: t('forum', 'Font size'),
example: t('forum', '{sizeStart}Hello world!{sizeEnd}', {
sizeStart: '[size=12]',
sizeEnd: '[/size]',
}),
},
{ tag: 'size', name: t('forum', 'Font size'), example: '[size=12]Hello world![/size]' },
{
tag: 'color',
name: t('forum', 'Font color'),
example: '[color=red]Hello world![/color]',
example: t('forum', '{colorStart}Hello world!{colorEnd}', {
colorStart: '[color=red]',
colorEnd: '[/color]',
}),
},
{
tag: 'left',
name: t('forum', 'Align left'),
example: t('forum', '{leftStart}Hello world{leftEnd}', {
leftStart: '[left]',
leftEnd: '[/left]',
}),
},
{ tag: 'left', name: t('forum', 'Text-align: left'), example: '[left]Hello world[/left]' },
{
tag: 'center',
name: t('forum', 'Text-align: center'),
example: '[center]Hello world[/center]',
name: t('forum', 'Align center'),
example: t('forum', '{centerStart}Hello world{centerEnd}', {
centerStart: '[center]',
centerEnd: '[/center]',
}),
},
{
tag: 'right',
name: t('forum', 'Text-align: right'),
example: '[right]Hello world[/right]',
name: t('forum', 'Align right'),
example: t('forum', '{rightStart}Hello world{rightEnd}', {
rightStart: '[right]',
rightEnd: '[/right]',
}),
},
{
tag: 'list',
name: t('forum', 'List'),
example: '[list][*]Hello world![li]Hello moon![/li][/list]',
example: t(
'forum',
'{listStart}{item1Start}Hello world!{item2Start}Hello moon!{item2End}{listEnd}',
{
listStart: '[list]',
item1Start: '[*]',
item2Start: '[li]',
item2End: '[/li]',
listEnd: '[/list]',
},
),
},
{
tag: '*',
name: t('forum', 'List item within a list'),
example: '[*]Hello world!\\r\\n[*]Hello moon!',
example: t('forum', '{itemStart}Hello world!\\r\\n{itemStart}Hello moon!', {
itemStart: '[*]',
}),
},
{
tag: 'li',
name: t('forum', 'List item within a list (alias)'),
example: '[li]Hello world!\\r\\n[/li][li]Hello moon![/li]',
example: t('forum', '{liStart}Hello world!\\r\\n{liEnd}{liStart}Hello moon!{liEnd}', {
liStart: '[li]',
liEnd: '[/li]',
}),
},
] as BuiltInCode[],
strings: {
title: t('forum', 'BBCode Help'),
title: t('forum', 'BBCode help'),
builtInTitle: t('forum', 'Built-in BBCodes'),
builtInDescription: t('forum', 'These BBCodes are available by default.'),
customTitle: t('forum', 'Custom BBCodes'),

View File

@@ -107,7 +107,7 @@ export default defineComponent({
return {
showHelp: false,
strings: {
helpLabel: t('forum', 'BBCode Help'),
helpLabel: t('forum', 'BBCode help'),
emojiLabel: t('forum', 'Insert emoji'),
},
}

View File

@@ -127,7 +127,7 @@ export default defineComponent({
isEditing: false,
strings: {
edited: t('forum', 'Edited'),
reply: t('forum', 'Quote Reply'),
reply: t('forum', 'Quote reply'),
edit: t('forum', 'Edit'),
delete: t('forum', 'Delete'),
confirmDelete: t(

View File

@@ -51,7 +51,7 @@ export default defineComponent({
content: this.initialContent,
submitting: false,
strings: {
placeholder: t('forum', 'Edit your post...'),
placeholder: t('forum', 'Edit your post'),
cancel: t('forum', 'Cancel'),
save: t('forum', 'Save'),
confirmCancel: t('forum', 'Are you sure you want to discard your changes?'),

View File

@@ -175,10 +175,8 @@ export default defineComponent({
return t('forum', 'React with {emoji}', { emoji })
}
if (count === 1) {
return hasReacted
? t('forum', 'You reacted with {emoji}', { emoji })
: t('forum', '1 person reacted with {emoji}', { emoji })
if (count === 1 && hasReacted) {
return t('forum', 'You reacted with {emoji}', { emoji })
}
return hasReacted

View File

@@ -70,9 +70,9 @@ export default defineComponent({
content: '',
submitting: false,
strings: {
placeholder: t('forum', 'Write your reply...'),
placeholder: t('forum', 'Write your reply'),
cancel: t('forum', 'Cancel'),
submit: t('forum', 'Post Reply'),
submit: t('forum', 'Post reply'),
confirmCancel: t('forum', 'Are you sure you want to discard your reply?'),
},
}

View File

@@ -40,8 +40,8 @@
<span class="stat-icon">
<CommentIcon :size="20" />
</span>
<span class="stat-value">{{ (thread.postCount || 1) - 1 }}</span>
<span class="stat-label">{{ strings.replies((thread.postCount || 1) - 1) }}</span>
<span class="stat-value">{{ thread.postCount || 0 }}</span>
<span class="stat-label">{{ strings.replies(thread.postCount || 0) }}</span>
</div>
<div class="stat">
<span class="stat-icon">

View File

@@ -83,10 +83,10 @@ export default defineComponent({
submitting: false,
strings: {
titleLabel: t('forum', 'Title'),
titlePlaceholder: t('forum', 'Enter thread title...'),
contentPlaceholder: t('forum', 'Write your first post...'),
titlePlaceholder: t('forum', 'Enter thread title'),
contentPlaceholder: t('forum', 'Write your first post'),
cancel: t('forum', 'Cancel'),
submit: t('forum', 'Create Thread'),
submit: t('forum', 'Create thread'),
confirmCancel: t('forum', 'Are you sure you want to discard this thread?'),
},
}

View File

@@ -1,6 +1,7 @@
import { ref, computed } from 'vue'
import { ocs } from '@/axios'
import type { UserRole } from '@/types'
import { SystemRole } from '@/constants'
const userRoles = ref<UserRole[]>([])
const loading = ref<boolean>(false)
@@ -30,13 +31,11 @@ export function useUserRole() {
}
const isAdmin = computed<boolean>(() => {
// Admin role has ID 1 (from migration)
return userRoles.value.some((role) => role.roleId === 1)
return userRoles.value.some((role) => role.roleId === SystemRole.ADMIN)
})
const isModerator = computed<boolean>(() => {
// Moderator role has ID 2 (from migration)
return userRoles.value.some((role) => role.roleId === 2)
return userRoles.value.some((role) => role.roleId === SystemRole.MODERATOR)
})
const refresh = () => {

22
src/constants.ts Normal file
View File

@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
/**
* System role IDs
* These roles are created during app installation and cannot be deleted
*/
export const SystemRole = {
/** Admin role ID */
ADMIN: 1,
/** Moderator role ID */
MODERATOR: 2,
/** User role ID */
USER: 3,
} as const
/**
* Check if a role ID is a system role
*/
export function isSystemRole(roleId: number): boolean {
return Object.values(SystemRole).includes(roleId)
}

View File

@@ -112,3 +112,7 @@
color: var(--color-text-maxcontrast);
}
}
.dialog__actions {
gap: 12px !important;
}

View File

@@ -100,7 +100,7 @@ export default defineComponent({
forumSubtitle: t('forum', 'Welcome to the forum'),
strings: {
refresh: t('forum', 'Refresh'),
loading: t('forum', 'Loading…'),
loading: t('forum', 'Loading …'),
emptyTitle: t('forum', 'No categories yet'),
emptyDesc: t('forum', 'Categories will appear here once they are created.'),
noCategories: t('forum', 'No categories in this section'),

View File

@@ -148,10 +148,10 @@ export default defineComponent({
offset: 0,
strings: {
back: t('forum', 'Back to Categories'),
back: t('forum', 'Back to categories'),
refresh: t('forum', 'Refresh'),
newThread: t('forum', 'New Thread'),
loading: t('forum', 'Loading…'),
newThread: t('forum', 'New thread'),
loading: t('forum', 'Loading …'),
errorTitle: t('forum', 'Error loading category'),
emptyTitle: t('forum', 'No threads yet'),
emptyDesc: t('forum', 'Be the first to start a discussion in this category.'),

View File

@@ -88,11 +88,11 @@ export default defineComponent({
strings: {
back: t('forum', 'Back'),
title: t('forum', 'Create New Thread'),
subtitle: (categoryName: string) => t('forum', 'in {category}', { category: categoryName }),
loading: t('forum', 'Loading…'),
subtitle: (categoryName: string) => t('forum', 'In {category}', { category: categoryName }),
loading: t('forum', 'Loading …'),
errorTitle: t('forum', 'Error loading category'),
creating: t('forum', 'Creating thread…'),
success: t('forum', 'Thread created successfully'),
creating: t('forum', 'Creating thread …'),
success: t('forum', 'Thread created'),
errorCreating: t('forum', 'Failed to create thread'),
},
}

View File

@@ -202,7 +202,7 @@ export default defineComponent({
strings: {
back: t('forum', 'Back'),
refresh: t('forum', 'Refresh'),
loading: t('forum', 'Loading...'),
loading: t('forum', 'Loading'),
errorTitle: t('forum', 'Error'),
retry: t('forum', 'Retry'),
firstPost: t('forum', 'First post'),
@@ -214,7 +214,7 @@ export default defineComponent({
noThreadsDesc: t('forum', 'This user has not created any threads yet'),
noPosts: t('forum', 'No replies'),
noPostsDesc: t('forum', 'This user has not posted any replies yet'),
inThread: t('forum', 'in thread'),
inThread: t('forum', 'In thread'),
},
}
},

View File

@@ -179,18 +179,18 @@ export default defineComponent({
strings: {
searchTitle: t('forum', 'Search'),
searchPlaceholder: t('forum', 'Enter search query...'),
searchPlaceholder: t('forum', 'Enter search query'),
search: t('forum', 'Search'),
searchThreads: t('forum', 'Search in Threads'),
searchPosts: t('forum', 'Search in Posts'),
syntaxHelp: t('forum', 'Syntax Help'),
searchSyntax: t('forum', 'Search Syntax'),
searchThreads: t('forum', 'Search in threads'),
searchPosts: t('forum', 'Search in posts'),
syntaxHelp: t('forum', 'Syntax help'),
searchSyntax: t('forum', 'Search syntax'),
helpExactPhrase: t('forum', 'Match exact phrase'),
helpAnd: t('forum', 'Both terms required'),
helpOr: t('forum', 'Either term matches'),
helpGrouping: t('forum', 'Group conditions with parentheses'),
helpExclude: t('forum', 'Exclude term from results'),
searching: t('forum', 'Searching...'),
searching: t('forum', 'Searching'),
errorTitle: t('forum', 'Search Error'),
retry: t('forum', 'Retry'),
emptyTitle: t('forum', 'Enter a search query'),

View File

@@ -276,7 +276,7 @@ export default defineComponent({
t('forum', 'Back to {category}', { category: categoryName }),
refresh: t('forum', 'Refresh'),
reply: t('forum', 'Reply'),
loading: t('forum', 'Loading…'),
loading: t('forum', 'Loading …'),
errorTitle: t('forum', 'Error loading thread'),
emptyPostsTitle: t('forum', 'No posts yet'),
emptyPostsDesc: t('forum', 'Be the first to post in this thread.'),
@@ -494,7 +494,7 @@ export default defineComponent({
postCard.finishEdit()
}
showSuccess(t('forum', 'Post updated successfully'))
showSuccess(t('forum', 'Post updated'))
}
} catch (e) {
console.error('Failed to update post', e)
@@ -523,7 +523,7 @@ export default defineComponent({
)
if (response.data?.success && response.data.categorySlug) {
showSuccess(t('forum', 'Thread deleted successfully'))
showSuccess(t('forum', 'Thread deleted'))
// Navigate to the category
this.$router.push(`/c/${response.data.categorySlug}`)
}
@@ -537,7 +537,7 @@ export default defineComponent({
this.posts.splice(index, 1)
}
showSuccess(t('forum', 'Post deleted successfully'))
showSuccess(t('forum', 'Post deleted'))
}
} catch (e) {
console.error('Failed to delete post', e)

View File

@@ -121,7 +121,7 @@ export default defineComponent({
title: t('forum', 'Preferences'),
subtitle: t('forum', 'Customize your forum experience'),
back: t('forum', 'Back'),
loading: t('forum', 'Loading preferences…'),
loading: t('forum', 'Loading preferences …'),
errorTitle: t('forum', 'Error loading preferences'),
retry: t('forum', 'Retry'),
subscriptionsTitle: t('forum', 'Notifications'),
@@ -133,7 +133,7 @@ export default defineComponent({
),
save: t('forum', 'Save'),
cancel: t('forum', 'Cancel'),
saveSuccess: t('forum', 'Preferences saved successfully'),
saveSuccess: t('forum', 'Preferences saved'),
},
}
},

View File

@@ -332,10 +332,10 @@ export default defineComponent({
},
strings: {
title: t('forum', 'BBCode Management'),
title: t('forum', 'BBCode management'),
subtitle: t('forum', 'Manage custom BBCode tags for post formatting'),
help: t('forum', 'BBCode Help'),
loading: t('forum', 'Loading…'),
loading: t('forum', 'Loading …'),
errorTitle: t('forum', 'Error loading BBCodes'),
retry: t('forum', 'Retry'),
createBBCode: t('forum', 'Create BBCode'),
@@ -361,7 +361,7 @@ export default defineComponent({
tag: t('forum', 'Tag'),
tagPlaceholder: t('forum', 'e.g., b, i, url, color'),
tagHelp: t('forum', 'The BBCode tag name (without brackets)'),
replacementLabel: t('forum', 'HTML Replacement'),
replacementLabel: t('forum', 'HTML replacement'),
replacementPlaceholder: t(
'forum',
'e.g., {strongStart}{content}{strongEnd}',
@@ -381,7 +381,7 @@ export default defineComponent({
description: t('forum', 'Description'),
descriptionPlaceholder: t('forum', 'Brief description of what this BBCode does'),
enabledLabel: t('forum', 'Enabled'),
parseInnerLabel: t('forum', 'Parse Inner Content'),
parseInnerLabel: t('forum', 'Parse inner content'),
parseInnerHelp: t('forum', 'If enabled, BBCode tags inside this tag will also be parsed'),
update: t('forum', 'Update'),
create: t('forum', 'Create'),

View File

@@ -207,6 +207,7 @@ import PencilIcon from '@icons/Pencil.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import type { Category, CatHeader, Role } from '@/types'
import { SystemRole } from '@/constants'
export default defineComponent({
name: 'AdminCategoryEdit',
@@ -252,19 +253,19 @@ export default defineComponent({
strings: {
back: t('forum', 'Back'),
createCategory: t('forum', 'Create Category'),
editCategory: t('forum', 'Edit Category'),
createCategory: t('forum', 'Create category'),
editCategory: t('forum', 'Edit category'),
subtitle: t('forum', 'Configure category details'),
loading: t('forum', 'Loading…'),
loading: t('forum', 'Loading …'),
errorTitle: t('forum', 'Error loading category'),
retry: t('forum', 'Retry'),
basicInfo: t('forum', 'Basic Information'),
categoryHeader: t('forum', 'Category Header'),
basicInfo: t('forum', 'Basic information'),
categoryHeader: t('forum', 'Category header'),
selectHeader: t('forum', '-- Select a header --'),
name: t('forum', 'Name'),
namePlaceholder: t('forum', 'Enter category name'),
slug: t('forum', 'Slug'),
slugPlaceholder: t('forum', 'category-slug'),
slugPlaceholder: 'category-slug',
slugHelp: t('forum', 'URL-friendly identifier (e.g., "{slug}")', {
slug: 'general-discussion',
}),
@@ -278,13 +279,13 @@ export default defineComponent({
update: t('forum', 'Update'),
newHeader: t('forum', 'New'),
editHeader: t('forum', 'Edit'),
createHeaderTitle: t('forum', 'Create Category Header'),
editHeaderTitle: t('forum', 'Edit Category Header'),
headerName: t('forum', 'Header Name'),
createHeaderTitle: t('forum', 'Create category header'),
editHeaderTitle: t('forum', 'Edit category header'),
headerName: t('forum', 'Header name'),
headerNamePlaceholder: t('forum', 'Enter header name'),
headerDescription: t('forum', 'Header Description'),
headerDescription: t('forum', 'Header description'),
headerDescriptionPlaceholder: t('forum', 'Enter header description (optional)'),
headerSortOrder: t('forum', 'Sort Order'),
headerSortOrder: t('forum', 'Sort order'),
permissions: t('forum', 'Permissions'),
permissionsDescription: t(
'forum',
@@ -297,7 +298,7 @@ export default defineComponent({
'forum',
'Select roles that can moderate (edit/delete) content in this category',
),
selectRoles: t('forum', 'Select roles...'),
selectRoles: t('forum', 'Select roles'),
},
}
},
@@ -322,10 +323,13 @@ export default defineComponent({
}))
},
roleOptions(): Array<{ id: number; label: string }> {
return this.roles.map((role) => ({
id: role.id,
label: role.name,
}))
// Filter out Admin role - it has implicit full access to all categories
return this.roles
.filter((role) => role.id !== SystemRole.ADMIN)
.map((role) => ({
id: role.id,
label: role.name,
}))
},
},
watch: {
@@ -380,15 +384,15 @@ export default defineComponent({
await this.loadPermissions()
} else {
// When creating a new category, prefill with default roles
// View: Member (role ID 3)
const memberRole = this.roles.find((r) => r.id === 3)
// View: User role
const memberRole = this.roles.find((r) => r.id === SystemRole.USER)
if (memberRole) {
this.selectedViewRoles = [{ id: memberRole.id, label: memberRole.name }]
}
// Moderate: Admin (ID 1) and Moderator (ID 2)
const adminRole = this.roles.find((r) => r.id === 1)
const moderatorRole = this.roles.find((r) => r.id === 2)
// Moderate: Admin and Moderator
const adminRole = this.roles.find((r) => r.id === SystemRole.ADMIN)
const moderatorRole = this.roles.find((r) => r.id === SystemRole.MODERATOR)
this.selectedModerateRoles = []
if (adminRole) {
this.selectedModerateRoles.push({ id: adminRole.id, label: adminRole.name })

View File

@@ -435,21 +435,21 @@ export default defineComponent({
strings: {
title: t('forum', 'Categories'),
subtitle: t('forum', 'Manage forum categories and organization'),
loading: t('forum', 'Loading…'),
loading: t('forum', 'Loading …'),
errorTitle: t('forum', 'Error loading categories'),
retry: t('forum', 'Retry'),
createCategory: t('forum', 'Create Category'),
createCategory: t('forum', 'Create category'),
edit: t('forum', 'Edit'),
delete: t('forum', 'Delete'),
noCategories: t('forum', 'No categories in this header'),
deleteDialogTitle: t('forum', 'Delete Category'),
deleteDialogTitle: t('forum', 'Delete category'),
deleteConfirmMessage: (name: string) =>
t('forum', `Are you sure you want to delete the category "{name}"?`, { name }),
threadWarning: (count: number) =>
n(
'forum',
'This category contains %d thread.',
'This category contains %d threads.',
'This category contains %n thread.',
'This category contains %n threads.',
count,
),
whatToDoWithThreads: t('forum', 'What should happen to the threads?'),
@@ -459,18 +459,18 @@ export default defineComponent({
selectTargetCategory: t('forum', 'Select target category'),
selectCategory: t('forum', '-- Select a category --'),
cancel: t('forum', 'Cancel'),
deleteCategory: t('forum', 'Delete Category'),
createHeader: t('forum', 'Create Header'),
categoriesCount: (count: number) => n('forum', '%d category', '%d categories', count),
threadsCount: (count: number) => n('forum', '%d thread', '%d threads', count),
postsCount: (count: number) => n('forum', '%d post', '%d posts', count),
createHeaderTitle: t('forum', 'Create Category Header'),
editHeaderTitle: t('forum', 'Edit Category Header'),
headerName: t('forum', 'Header Name'),
deleteCategory: t('forum', 'Delete category'),
createHeader: t('forum', 'Create header'),
categoriesCount: (count: number) => n('forum', '%n category', '%n categories', count),
threadsCount: (count: number) => n('forum', '%n thread', '%n threads', count),
postsCount: (count: number) => n('forum', '%n post', '%n posts', count),
createHeaderTitle: t('forum', 'Create category header'),
editHeaderTitle: t('forum', 'Edit category header'),
headerName: t('forum', 'Header name'),
headerNamePlaceholder: t('forum', 'Enter header name'),
headerDescription: t('forum', 'Header Description'),
headerDescription: t('forum', 'Header description'),
headerDescriptionPlaceholder: t('forum', 'Enter header description (optional)'),
headerSortOrder: t('forum', 'Sort Order'),
headerSortOrder: t('forum', 'Sort order'),
sortOrderPlaceholder: t('forum', '0'),
sortOrderHelp: t('forum', 'Lower numbers appear first'),
update: t('forum', 'Update'),
@@ -481,8 +481,8 @@ export default defineComponent({
headerCategoryWarning: (count: number) =>
n(
'forum',
'This header contains %d category.',
'This header contains %d categories.',
'This header contains %n category.',
'This header contains %n categories.',
count,
),
deleteHeaderHelp: t('forum', 'This action cannot be undone'),

View File

@@ -231,24 +231,24 @@ export default defineComponent({
error: null as string | null,
strings: {
title: t('forum', 'Admin Dashboard'),
title: t('forum', 'Admin dashboard'),
subtitle: t('forum', 'Overview of forum activity and statistics'),
loading: t('forum', 'Loading statistics…'),
loading: t('forum', 'Loading statistics …'),
errorTitle: t('forum', 'Error loading dashboard'),
retry: t('forum', 'Retry'),
totals: t('forum', 'Total Statistics'),
totalUsers: t('forum', 'Total Users'),
totalThreads: t('forum', 'Total Threads'),
totalPosts: t('forum', 'Total Posts'),
totalCategories: t('forum', 'Total Categories'),
totals: t('forum', 'Total statistics'),
totalUsers: t('forum', 'Total users'),
totalThreads: t('forum', 'Total threads'),
totalPosts: t('forum', 'Total posts'),
totalCategories: t('forum', 'Total categories'),
recentActivity: t('forum', 'Recent Activity (Last 7 Days)'),
newUsers: t('forum', 'New Users'),
newThreads: t('forum', 'New Threads'),
newPosts: t('forum', 'New Posts'),
topContributors: t('forum', 'Top Contributors'),
newUsers: t('forum', 'New users'),
newThreads: t('forum', 'New threads'),
newPosts: t('forum', 'New posts'),
topContributors: t('forum', 'Top contributors'),
noContributors: t('forum', 'No contributors yet'),
last7Days: t('forum', 'Last 7 Days'),
allTime: t('forum', 'All Time'),
last7Days: t('forum', 'Last 7 days'),
allTime: t('forum', 'All time'),
threadsCount: (count: number) => n('forum', '%n thread', '%n threads', count),
postsCount: (count: number) => n('forum', '%n post', '%n posts', count),
},

View File

@@ -121,22 +121,22 @@ export default defineComponent({
} as Settings,
strings: {
title: t('forum', 'General Settings'),
title: t('forum', 'General settings'),
subtitle: t('forum', 'Configure general forum settings'),
loading: t('forum', 'Loading settings…'),
loading: t('forum', 'Loading settings …'),
errorTitle: t('forum', 'Error loading settings'),
retry: t('forum', 'Retry'),
appearanceTitle: t('forum', 'Appearance'),
appearanceDesc: t('forum', 'Customize how your forum looks to users'),
forumTitle: t('forum', 'Forum Title'),
forumTitle: t('forum', 'Forum title'),
forumTitlePlaceholder: t('forum', 'Forum'),
forumTitleHint: t('forum', 'Displayed at the top of the forum home page'),
forumSubtitle: t('forum', 'Forum Subtitle'),
forumSubtitle: t('forum', 'Forum subtitle'),
forumSubtitlePlaceholder: t('forum', 'Welcome to the forum'),
forumSubtitleHint: t('forum', 'A brief description shown below the title'),
save: t('forum', 'Save'),
cancel: t('forum', 'Cancel'),
saveSuccess: t('forum', 'Settings saved successfully'),
saveSuccess: t('forum', 'Settings saved'),
},
}
},
@@ -242,7 +242,7 @@ export default defineComponent({
font-weight: 600;
}
> p {
>p {
margin: 0 0 20px 0;
font-size: 0.9rem;
}

View File

@@ -115,21 +115,21 @@
<div class="permissions-checkboxes">
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canAccessAdminTools">
<NcCheckboxRadioSwitch v-model="formData.canAccessAdminTools" :disabled="isAdmin">
<strong>{{ strings.canAccessAdminTools }}</strong>
<span class="checkbox-desc muted">{{ strings.canAccessAdminToolsDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canEditRoles">
<NcCheckboxRadioSwitch v-model="formData.canEditRoles" :disabled="isAdmin">
<strong>{{ strings.canEditRoles }}</strong>
<span class="checkbox-desc muted">{{ strings.canEditRolesDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canEditCategories">
<NcCheckboxRadioSwitch v-model="formData.canEditCategories" :disabled="isAdmin">
<strong>{{ strings.canEditCategories }}</strong>
<span class="checkbox-desc muted">{{ strings.canEditCategoriesDesc }}</span>
</NcCheckboxRadioSwitch>
@@ -223,6 +223,7 @@ import AppToolbar from '@/components/AppToolbar.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import type { Role, CategoryHeader } from '@/types'
import { SystemRole, isSystemRole } from '@/constants'
interface CategoryPermission {
canView: boolean
@@ -265,13 +266,13 @@ export default defineComponent({
strings: {
back: t('forum', 'Back'),
createRole: t('forum', 'Create Role'),
editRole: t('forum', 'Edit Role'),
createRole: t('forum', 'Create role'),
editRole: t('forum', 'Edit role'),
subtitle: t('forum', 'Configure role permissions and category access'),
loading: t('forum', 'Loading…'),
loading: t('forum', 'Loading …'),
errorTitle: t('forum', 'Error loading role'),
retry: t('forum', 'Retry'),
basicInfo: t('forum', 'Basic Information'),
basicInfo: t('forum', 'Basic information'),
name: t('forum', 'Name'),
description: t('forum', 'Description'),
namePlaceholder: t('forum', 'Enter role name'),
@@ -279,24 +280,24 @@ export default defineComponent({
systemRoleNameWarning: t('forum', 'System role names cannot be changed'),
colors: t('forum', 'Colors'),
colorsDesc: t('forum', 'Set colors for this role badge'),
colorLight: t('forum', 'Light Mode Color'),
colorDark: t('forum', 'Dark Mode Color'),
colorLightPlaceholder: t('forum', '#000000'),
colorDarkPlaceholder: t('forum', '#ffffff'),
colorLight: t('forum', 'Light mode color'),
colorDark: t('forum', 'Dark mode color'),
colorLightPlaceholder: '#000000',
colorDarkPlaceholder: '#ffffff',
reset: t('forum', 'Reset'),
rolePermissions: t('forum', 'Role Permissions'),
rolePermissionsDesc: t('forum', 'Set global permissions for this role'),
canAccessAdminTools: t('forum', 'Can Access Admin Tools'),
canAccessAdminToolsDesc: t('forum', 'Allow access to the admin dashboard and tools'),
canEditRoles: t('forum', 'Can Edit Roles'),
canEditRolesDesc: t('forum', 'Allow creating, editing, and deleting roles'),
canEditCategories: t('forum', 'Can Edit Categories'),
canEditCategoriesDesc: t('forum', 'Allow creating, editing, and deleting categories'),
categoryPermissions: t('forum', 'Category Permissions'),
canEditRoles: t('forum', 'Can edit roles'),
canEditRolesDesc: t('forum', 'Allow creating, editing and deleting roles'),
canEditCategories: t('forum', 'Can edit categories'),
canEditCategoriesDesc: t('forum', 'Allow creating, editing and deleting categories'),
categoryPermissions: t('forum', 'Category permissions'),
categoryPermissionsDesc: t('forum', 'Set which categories this role can access'),
category: t('forum', 'Category'),
canView: t('forum', 'Can View'),
canModerate: t('forum', 'Can Moderate'),
canView: t('forum', 'Can view'),
canModerate: t('forum', 'Can moderate'),
allow: t('forum', 'Allow'),
noCategories: t('forum', 'No categories available'),
adminFullAccess: t('forum', 'Admin role has full access to all categories'),
@@ -314,12 +315,12 @@ export default defineComponent({
return this.$route.params.id ? parseInt(this.$route.params.id as string) : null
},
isSystemRole(): boolean {
// System roles (Admin, Moderator, Member) - only name is locked
return this.roleId !== null && this.roleId <= 3
// System roles (Admin, Moderator, User) - only name is locked
return this.roleId !== null && isSystemRole(this.roleId)
},
isAdmin(): boolean {
// Admin role (ID 1) has full access to everything
return this.roleId === 1
// Admin role has full access to everything
return this.roleId === SystemRole.ADMIN
},
canSubmit(): boolean {
return this.formData.name.trim().length > 0
@@ -398,6 +399,13 @@ export default defineComponent({
this.formData.canEditRoles = role.canEditRoles || false
this.formData.canEditCategories = role.canEditCategories || false
// Admin role always has all permissions
if (this.isAdmin) {
this.formData.canAccessAdminTools = true
this.formData.canEditRoles = true
this.formData.canEditCategories = true
}
// If colors are different, mark dark as modified
if (role.colorLight && role.colorDark && role.colorLight !== role.colorDark) {
this.darkColorModified = true
@@ -451,9 +459,9 @@ export default defineComponent({
description: this.formData.description.trim() || null,
colorLight: this.formData.colorLight || null,
colorDark: this.formData.colorDark || null,
canAccessAdminTools: this.formData.canAccessAdminTools,
canEditRoles: this.formData.canEditRoles,
canEditCategories: this.formData.canEditCategories,
canAccessAdminTools: this.isAdmin ? true : this.formData.canAccessAdminTools,
canEditRoles: this.isAdmin ? true : this.formData.canEditRoles,
canEditCategories: this.isAdmin ? true : this.formData.canEditCategories,
}
let roleId: number

View File

@@ -117,6 +117,7 @@ import AppToolbar from '@/components/AppToolbar.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import type { Role } from '@/types'
import { SystemRole, isSystemRole } from '@/constants'
export default defineComponent({
name: 'AdminRoleList',
@@ -141,16 +142,17 @@ export default defineComponent({
loading: false,
roles: [] as Role[],
error: null as string | null,
SystemRole, // Expose SystemRole constant to template
strings: {
title: t('forum', 'Role Management'),
title: t('forum', 'Role management'),
subtitle: t('forum', 'Create and manage forum roles and permissions'),
loading: t('forum', 'Loading roles…'),
loading: t('forum', 'Loading roles …'),
errorTitle: t('forum', 'Error loading roles'),
retry: t('forum', 'Retry'),
emptyTitle: t('forum', 'No roles found'),
emptyDesc: t('forum', 'Create your first role to get started'),
createRole: t('forum', 'Create Role'),
createRole: t('forum', 'Create role'),
id: t('forum', 'ID'),
name: t('forum', 'Name'),
description: t('forum', 'Description'),
@@ -198,10 +200,7 @@ export default defineComponent({
}
},
isSystemRole(roleId: number): boolean {
// System roles (Admin, Moderator, Member) cannot be deleted
return roleId <= 3
},
isSystemRole,
createRole(): void {
this.$router.push('/admin/roles/create')

View File

@@ -189,9 +189,9 @@ export default defineComponent({
originalRoles: [] as number[],
strings: {
title: t('forum', 'User Management'),
subtitle: t('forum', 'Manage forum users, roles, and permissions'),
loading: t('forum', 'Loading users…'),
title: t('forum', 'User management'),
subtitle: t('forum', 'Manage forum users, roles and permissions'),
loading: t('forum', 'Loading users …'),
errorTitle: t('forum', 'Error loading users'),
retry: t('forum', 'Retry'),
emptyTitle: t('forum', 'No users found'),
@@ -207,7 +207,7 @@ export default defineComponent({
noRoles: t('forum', 'No roles'),
selectRoles: t('forum', 'Select roles'),
editRoles: t('forum', 'Edit roles'),
editRolesTitle: t('forum', 'Edit User Roles'),
editRolesTitle: t('forum', 'Edit user roles'),
save: t('forum', 'Save'),
cancel: t('forum', 'Cancel'),
},

14
tests/phpunit.docker.xml Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="bootstrap.php"
timeoutForSmallTests="900"
timeoutForMediumTests="900"
timeoutForLargeTests="900"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
colors="true">
<testsuites>
<testsuite name="Forum Tests">
<directory suffix="Test.php">.</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="bootstrap.php"
cacheDirectory=".phpunit.cache"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
colors="true">
<testsuites>
<testsuite name="Forum Tests">
<directory suffix="Test.php">.</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">../appinfo</directory>
<directory suffix=".php">../lib</directory>
</include>
</source>
</phpunit>

View File

@@ -1,14 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="bootstrap.php" timeoutForSmallTests="900" timeoutForMediumTests="900" timeoutForLargeTests="900" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
<coverage>
<include>
<directory suffix=".php">../appinfo</directory>
<directory suffix=".php">../lib</directory>
</include>
</coverage>
<testsuites>
<testsuite name="Forum Tests">
<directory suffix="Test.php">.</directory>
</testsuite>
</testsuites>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="bootstrap.php"
timeoutForSmallTests="900"
timeoutForMediumTests="900"
timeoutForLargeTests="900"
cacheDirectory=".phpunit.cache"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
colors="true">
<testsuites>
<testsuite name="Forum Tests">
<directory suffix="Test.php">.</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">../appinfo</directory>
<directory suffix=".php">../lib</directory>
</include>
</source>
</phpunit>

View File

@@ -8,15 +8,19 @@ use OCA\Forum\AppInfo\Application;
use OCA\Forum\Controller\CategoryController;
use OCA\Forum\Db\Category;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\CategoryPerm;
use OCA\Forum\Db\CategoryPermMapper;
use OCA\Forum\Db\CatHeader;
use OCA\Forum\Db\CatHeaderMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\UserRole;
use OCA\Forum\Db\UserRoleMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
@@ -394,6 +398,268 @@ class CategoryControllerTest extends TestCase {
$this->assertEquals(['error' => 'Target category not found'], $response->getData());
}
public function testCheckPermissionReturnsTrue(): void {
$categoryId = 1;
$permission = 'canView';
$userId = 'user1';
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
// User is not an admin
$this->groupManager->method('get')->with('admin')->willReturn(null);
// User has a role
$userRole = new UserRole();
$userRole->setId(1);
$userRole->setUserId($userId);
$userRole->setRoleId(1);
$this->userRoleMapper->expects($this->once())
->method('findByUserId')
->with($userId)
->willReturn([$userRole]);
// Category permission allows viewing
$categoryPerm = new CategoryPerm();
$categoryPerm->setId(1);
$categoryPerm->setCategoryId($categoryId);
$categoryPerm->setRoleId(1);
$categoryPerm->setCanView(true);
$categoryPerm->setCanPost(false);
$categoryPerm->setCanReply(false);
$categoryPerm->setCanModerate(false);
$this->categoryPermMapper->expects($this->once())
->method('findByCategoryAndRoles')
->with($categoryId, [1])
->willReturn([$categoryPerm]);
$response = $this->controller->checkPermission($categoryId, $permission);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['hasPermission']);
}
public function testCheckPermissionReturnsFalseWhenNoPermission(): void {
$categoryId = 1;
$permission = 'canModerate';
$userId = 'user1';
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$this->groupManager->method('get')->with('admin')->willReturn(null);
$userRole = new UserRole();
$userRole->setId(1);
$userRole->setUserId($userId);
$userRole->setRoleId(1);
$this->userRoleMapper->expects($this->once())
->method('findByUserId')
->willReturn([$userRole]);
// Category permission does not allow moderating
$categoryPerm = new CategoryPerm();
$categoryPerm->setId(1);
$categoryPerm->setCategoryId($categoryId);
$categoryPerm->setRoleId(1);
$categoryPerm->setCanView(true);
$categoryPerm->setCanPost(false);
$categoryPerm->setCanReply(false);
$categoryPerm->setCanModerate(false);
$this->categoryPermMapper->expects($this->once())
->method('findByCategoryAndRoles')
->with($categoryId, [1])
->willReturn([$categoryPerm]);
$response = $this->controller->checkPermission($categoryId, $permission);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertFalse($data['hasPermission']);
}
public function testCheckPermissionReturnsTrueForAdmin(): void {
$categoryId = 1;
$permission = 'canModerate';
$userId = 'admin1';
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
// User is in admin group
$adminGroup = $this->createMock(IGroup::class);
$adminGroup->method('inGroup')->with($user)->willReturn(true);
$this->groupManager->method('get')->with('admin')->willReturn($adminGroup);
$response = $this->controller->checkPermission($categoryId, $permission);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['hasPermission']);
}
public function testGetPermissionsReturnsPermissionsSuccessfully(): void {
$categoryId = 1;
// Note: Only non-admin roles (2, 3) are returned - Admin role is excluded
$perm1 = new CategoryPerm();
$perm1->setId(1);
$perm1->setCategoryId($categoryId);
$perm1->setRoleId(2);
$perm1->setCanView(true);
$perm1->setCanPost(true);
$perm1->setCanReply(true);
$perm1->setCanModerate(false);
$perm2 = new CategoryPerm();
$perm2->setId(2);
$perm2->setCategoryId($categoryId);
$perm2->setRoleId(3);
$perm2->setCanView(true);
$perm2->setCanPost(false);
$perm2->setCanReply(false);
$perm2->setCanModerate(false);
$this->categoryPermMapper->expects($this->once())
->method('findByCategoryIdExcludingAdmin')
->with($categoryId)
->willReturn([$perm1, $perm2]);
$response = $this->controller->getPermissions($categoryId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertIsArray($data);
$this->assertCount(2, $data);
$this->assertEquals(2, $data[0]['roleId']);
$this->assertTrue($data[0]['canView']);
$this->assertFalse($data[0]['canModerate']);
}
public function testUpdatePermissionsSuccessfully(): void {
$categoryId = 1;
$permissions = [
['roleId' => 2, 'canView' => true, 'canModerate' => false],
['roleId' => 3, 'canView' => true, 'canModerate' => true],
];
$category = $this->createCategory($categoryId, 1, 'Test Category');
$this->categoryMapper->expects($this->once())
->method('find')
->with($categoryId)
->willReturn($category);
$this->categoryPermMapper->expects($this->once())
->method('deleteByCategoryId')
->with($categoryId);
$this->categoryPermMapper->expects($this->exactly(2))
->method('insert')
->willReturnCallback(function ($perm) {
return $perm;
});
$response = $this->controller->updatePermissions($categoryId, $permissions);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['success']);
}
public function testUpdatePermissionsFiltersOutAdminRole(): void {
$categoryId = 1;
$permissions = [
['roleId' => 1, 'canView' => true, 'canModerate' => true], // Admin - should be filtered
['roleId' => 2, 'canView' => true, 'canModerate' => false],
['roleId' => 3, 'canView' => true, 'canModerate' => false],
];
$category = $this->createCategory($categoryId, 1, 'Test Category');
$this->categoryMapper->expects($this->once())
->method('find')
->with($categoryId)
->willReturn($category);
$this->categoryPermMapper->expects($this->once())
->method('deleteByCategoryId')
->with($categoryId);
// Should only insert 2 permissions (Admin role ID 1 is filtered out)
$this->categoryPermMapper->expects($this->exactly(2))
->method('insert')
->willReturnCallback(function ($perm) {
// Verify that Admin role (ID 1) is never inserted
$this->assertNotEquals(1, $perm->getRoleId());
return $perm;
});
$response = $this->controller->updatePermissions($categoryId, $permissions);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['success']);
}
public function testUpdatePermissionsReturnsNotFoundWhenCategoryDoesNotExist(): void {
$categoryId = 999;
$permissions = [
['roleId' => 1, 'canView' => true, 'canModerate' => false],
];
$this->categoryMapper->expects($this->once())
->method('find')
->with($categoryId)
->willThrowException(new DoesNotExistException('Category not found'));
$response = $this->controller->updatePermissions($categoryId, $permissions);
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
$this->assertEquals(['error' => 'Category not found'], $response->getData());
}
public function testReorderUpdatesCategories(): void {
$categories = [
['id' => 1, 'sortOrder' => 2],
['id' => 2, 'sortOrder' => 1],
];
$category1 = $this->createCategory(1, 1, 'Category 1');
$category2 = $this->createCategory(2, 1, 'Category 2');
$this->categoryMapper->expects($this->exactly(2))
->method('find')
->willReturnCallback(function ($id) use ($category1, $category2) {
return $id === 1 ? $category1 : $category2;
});
$this->categoryMapper->expects($this->exactly(2))
->method('update')
->willReturnCallback(function ($category) use ($categories) {
if ($category->getId() === 1) {
$this->assertEquals(2, $category->getSortOrder());
} else {
$this->assertEquals(1, $category->getSortOrder());
}
return $category;
});
$response = $this->controller->reorder($categories);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['success']);
}
private function createCatHeader(int $id, string $name): CatHeader {
$header = new CatHeader();
$header->setId($id);

View File

@@ -545,4 +545,211 @@ class PostControllerTest extends TestCase {
$reaction->setCreatedAt(time());
return $reaction;
}
public function testCreatePostIncrementsThreadPostCount(): void {
$threadId = 1;
$content = 'New reply post content';
$userId = 'user1';
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$thread = new Thread();
$thread->setId($threadId);
$thread->setCategoryId(1);
$thread->setPostCount(5); // Thread has 5 replies
$category = new Category();
$category->setId(1);
$category->setPostCount(10); // Category has 10 total replies
$createdPost = $this->createMockPost(1, $threadId, $userId, $content);
$this->postMapper->expects($this->once())
->method('insert')
->willReturn($createdPost);
$this->readMarkerMapper->method('createOrUpdate');
$this->threadMapper->expects($this->once())
->method('find')
->willReturn($thread);
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) {
// Thread post count should be incremented from 5 to 6
$this->assertEquals(6, $updatedThread->getPostCount());
return $updatedThread;
});
$this->categoryMapper->expects($this->once())
->method('find')
->willReturn($category);
$this->categoryMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedCategory) {
// Category post count should be incremented from 10 to 11
$this->assertEquals(11, $updatedCategory->getPostCount());
return $updatedCategory;
});
$this->userStatsMapper->expects($this->once())
->method('incrementPostCount')
->with($userId);
$this->notificationService->method('notifyThreadSubscribers');
$response = $this->controller->create($threadId, $content);
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
}
public function testDestroyPostDecrementsThreadPostCount(): void {
$postId = 1;
$userId = 'user1';
$post = $this->createMockPost($postId, 1, $userId, 'Test content');
$post->setIsFirstPost(false); // Regular reply post
$thread = new Thread();
$thread->setId(1);
$thread->setCategoryId(1);
$thread->setPostCount(5); // Thread has 5 replies
$thread->setLastPostId($postId); // This post is the last post
$category = new Category();
$category->setId(1);
$category->setPostCount(10); // Category has 10 total replies
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$this->permissionService->method('getCategoryIdFromPost')->willReturn(1);
$this->permissionService->method('hasCategoryPermission')->willReturn(false);
$this->postMapper->expects($this->once())
->method('find')
->willReturn($post);
$this->postMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedPost) {
$this->assertNotNull($updatedPost->getDeletedAt());
return $updatedPost;
});
$this->threadMapper->expects($this->once())
->method('find')
->willReturn($thread);
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) {
// Thread post count should be decremented from 5 to 4
$this->assertEquals(4, $updatedThread->getPostCount());
return $updatedThread;
});
// Mock finding the last post (not the deleted one)
$lastPost = $this->createMockPost(2, 1, $userId, 'Last post');
$this->postMapper->expects($this->once())
->method('findLatestByThreadId')
->with(1, $postId)
->willReturn($lastPost);
$this->categoryMapper->expects($this->once())
->method('find')
->willReturn($category);
$this->categoryMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedCategory) {
// Category post count should be decremented from 10 to 9
$this->assertEquals(9, $updatedCategory->getPostCount());
return $updatedCategory;
});
$this->userStatsMapper->expects($this->once())
->method('decrementPostCount')
->with($userId);
$response = $this->controller->destroy($postId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
}
public function testDestroyFirstPostDecrementsThreadCount(): void {
$postId = 1;
$userId = 'user1';
$post = $this->createMockPost($postId, 1, $userId, 'First post content');
$post->setIsFirstPost(true); // First post
$thread = new Thread();
$thread->setId(1);
$thread->setCategoryId(1);
$thread->setPostCount(3); // Thread has 3 replies
$thread->setLastPostId($postId); // This post is the last post
$category = new Category();
$category->setId(1);
$category->setPostCount(10); // Category has 10 total replies
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$this->permissionService->method('getCategoryIdFromPost')->willReturn(1);
$this->permissionService->method('hasCategoryPermission')->willReturn(false);
$this->postMapper->expects($this->once())
->method('find')
->willReturn($post);
$this->postMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedPost) {
$this->assertNotNull($updatedPost->getDeletedAt());
return $updatedPost;
});
$this->threadMapper->expects($this->once())
->method('find')
->willReturn($thread);
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) {
// Thread post count should stay at 3 (first posts don't count)
$this->assertEquals(3, $updatedThread->getPostCount());
return $updatedThread;
});
$lastPost = $this->createMockPost(2, 1, $userId, 'Last post');
$this->postMapper->expects($this->once())
->method('findLatestByThreadId')
->with(1, $postId)
->willReturn($lastPost);
// Category mapper should not be called for first post deletion
$this->categoryMapper->expects($this->never())
->method('find');
$this->categoryMapper->expects($this->never())
->method('update');
// First post deletion should decrement thread count, not post count
$this->userStatsMapper->expects($this->once())
->method('decrementThreadCount')
->with($userId);
$this->userStatsMapper->expects($this->never())
->method('decrementPostCount');
$response = $this->controller->destroy($postId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
}
}

View File

@@ -0,0 +1,433 @@
<?php
declare(strict_types=1);
namespace OCA\Forum\Tests\Controller;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Controller\RoleController;
use OCA\Forum\Db\CategoryPermMapper;
use OCA\Forum\Db\Role;
use OCA\Forum\Db\RoleMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\IRequest;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class RoleControllerTest extends TestCase {
private RoleController $controller;
private RoleMapper $roleMapper;
private CategoryPermMapper $categoryPermMapper;
private LoggerInterface $logger;
private IRequest $request;
protected function setUp(): void {
$this->request = $this->createMock(IRequest::class);
$this->roleMapper = $this->createMock(RoleMapper::class);
$this->categoryPermMapper = $this->createMock(CategoryPermMapper::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->controller = new RoleController(
Application::APP_ID,
$this->request,
$this->roleMapper,
$this->categoryPermMapper,
$this->logger
);
}
public function testUpdateAdminRoleEnforcesAllPermissions(): void {
$adminRoleId = 1;
$adminRole = $this->createRole($adminRoleId, 'Admin', true, true, true);
$this->roleMapper->expects($this->once())
->method('find')
->with($adminRoleId)
->willReturn($adminRole);
$this->roleMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($role) use ($adminRoleId) {
// Verify Admin role always has all permissions
$this->assertEquals($adminRoleId, $role->getId());
$this->assertTrue($role->getCanAccessAdminTools());
$this->assertTrue($role->getCanEditRoles());
$this->assertTrue($role->getCanEditCategories());
return $role;
});
// Try to update Admin role with permissions set to false - should be forced to true
$response = $this->controller->update(
$adminRoleId,
'Admin',
'Administrator role',
'#ff0000',
'#ff0000',
false, // Try to disable - should be forced to true
false, // Try to disable - should be forced to true
false // Try to disable - should be forced to true
);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['canAccessAdminTools']);
$this->assertTrue($data['canEditRoles']);
$this->assertTrue($data['canEditCategories']);
}
public function testUpdateNonAdminRoleAllowsPermissionChanges(): void {
$roleId = 2;
$role = $this->createRole($roleId, 'Moderator', true, false, true);
$this->roleMapper->expects($this->once())
->method('find')
->with($roleId)
->willReturn($role);
$this->roleMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($role) use ($roleId) {
// Verify non-admin role can have permissions changed
$this->assertEquals($roleId, $role->getId());
$this->assertFalse($role->getCanAccessAdminTools());
$this->assertTrue($role->getCanEditRoles());
$this->assertFalse($role->getCanEditCategories());
return $role;
});
$response = $this->controller->update(
$roleId,
'Moderator',
'Moderator role',
null,
null,
false, // Changed from true
true, // Kept true
false // Changed from true
);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
}
public function testDeleteAdminRoleReturnsForbidden(): void {
$adminRoleId = 1;
// Should not even try to find the role - should reject immediately
$this->roleMapper->expects($this->never())
->method('find');
$this->roleMapper->expects($this->never())
->method('delete');
$response = $this->controller->destroy($adminRoleId);
$this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus());
$data = $response->getData();
$this->assertEquals(['error' => 'System roles cannot be deleted'], $data);
}
public function testDeleteModeratorRoleReturnsForbidden(): void {
$moderatorRoleId = 2;
$this->roleMapper->expects($this->never())
->method('find');
$this->roleMapper->expects($this->never())
->method('delete');
$response = $this->controller->destroy($moderatorRoleId);
$this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus());
$data = $response->getData();
$this->assertEquals(['error' => 'System roles cannot be deleted'], $data);
}
public function testDeleteUserRoleReturnsForbidden(): void {
$userRoleId = 3;
$this->roleMapper->expects($this->never())
->method('find');
$this->roleMapper->expects($this->never())
->method('delete');
$response = $this->controller->destroy($userRoleId);
$this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus());
$data = $response->getData();
$this->assertEquals(['error' => 'System roles cannot be deleted'], $data);
}
public function testDeleteCustomRoleSuccessfully(): void {
$customRoleId = 4;
$customRole = $this->createRole($customRoleId, 'Custom Role', false, false, false);
$this->roleMapper->expects($this->once())
->method('find')
->with($customRoleId)
->willReturn($customRole);
$this->categoryPermMapper->expects($this->once())
->method('deleteByRoleId')
->with($customRoleId);
$this->roleMapper->expects($this->once())
->method('delete')
->with($customRole);
$response = $this->controller->destroy($customRoleId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['success']);
}
public function testDeleteNonExistentRoleReturnsNotFound(): void {
$roleId = 999;
$this->roleMapper->expects($this->once())
->method('find')
->with($roleId)
->willThrowException(new DoesNotExistException('Role not found'));
$this->roleMapper->expects($this->never())
->method('delete');
$response = $this->controller->destroy($roleId);
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
$data = $response->getData();
$this->assertEquals(['error' => 'Role not found'], $data);
}
public function testIndexReturnsAllRoles(): void {
$role1 = $this->createRole(1, 'Admin', true, true, true);
$role2 = $this->createRole(2, 'Moderator', true, false, true);
$role3 = $this->createRole(3, 'User', false, false, false);
$this->roleMapper->expects($this->once())
->method('findAll')
->willReturn([$role1, $role2, $role3]);
$response = $this->controller->index();
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertIsArray($data);
$this->assertCount(3, $data);
$this->assertEquals('Admin', $data[0]['name']);
$this->assertEquals('Moderator', $data[1]['name']);
$this->assertEquals('User', $data[2]['name']);
}
public function testShowReturnsRoleSuccessfully(): void {
$roleId = 2;
$role = $this->createRole($roleId, 'Moderator', true, false, true);
$this->roleMapper->expects($this->once())
->method('find')
->with($roleId)
->willReturn($role);
$response = $this->controller->show($roleId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertEquals($roleId, $data['id']);
$this->assertEquals('Moderator', $data['name']);
$this->assertTrue($data['canAccessAdminTools']);
$this->assertFalse($data['canEditRoles']);
$this->assertTrue($data['canEditCategories']);
}
public function testShowReturnsNotFoundWhenRoleDoesNotExist(): void {
$roleId = 999;
$this->roleMapper->expects($this->once())
->method('find')
->with($roleId)
->willThrowException(new DoesNotExistException('Role not found'));
$response = $this->controller->show($roleId);
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
$data = $response->getData();
$this->assertEquals(['error' => 'Role not found'], $data);
}
public function testCreateRoleSuccessfully(): void {
$name = 'Custom Role';
$description = 'A custom role for special users';
$colorLight = '#ff5722';
$colorDark = '#d84315';
$this->roleMapper->expects($this->once())
->method('insert')
->willReturnCallback(function ($role) use ($name, $description, $colorLight, $colorDark) {
$this->assertEquals($name, $role->getName());
$this->assertEquals($description, $role->getDescription());
$this->assertEquals($colorLight, $role->getColorLight());
$this->assertEquals($colorDark, $role->getColorDark());
$this->assertTrue($role->getCanAccessAdminTools());
$this->assertFalse($role->getCanEditRoles());
$this->assertTrue($role->getCanEditCategories());
// Simulate DB setting ID
$role->setId(4);
return $role;
});
$response = $this->controller->create(
$name,
$description,
$colorLight,
$colorDark,
true, // canAccessAdminTools
false, // canEditRoles
true // canEditCategories
);
$this->assertEquals(Http::STATUS_CREATED, $response->getStatus());
$data = $response->getData();
$this->assertEquals(4, $data['id']);
$this->assertEquals($name, $data['name']);
$this->assertEquals($description, $data['description']);
}
public function testUpdateRoleReturnsNotFoundWhenRoleDoesNotExist(): void {
$roleId = 999;
$this->roleMapper->expects($this->once())
->method('find')
->with($roleId)
->willThrowException(new DoesNotExistException('Role not found'));
$this->roleMapper->expects($this->never())
->method('update');
$response = $this->controller->update($roleId, 'New Name');
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
$data = $response->getData();
$this->assertEquals(['error' => 'Role not found'], $data);
}
public function testGetPermissionsReturnsPermissionsForRole(): void {
$roleId = 2;
$perm1 = new \OCA\Forum\Db\CategoryPerm();
$perm1->setId(1);
$perm1->setCategoryId(1);
$perm1->setRoleId($roleId);
$perm1->setCanView(true);
$perm1->setCanPost(true);
$perm1->setCanReply(true);
$perm1->setCanModerate(false);
$perm2 = new \OCA\Forum\Db\CategoryPerm();
$perm2->setId(2);
$perm2->setCategoryId(2);
$perm2->setRoleId($roleId);
$perm2->setCanView(true);
$perm2->setCanPost(false);
$perm2->setCanReply(false);
$perm2->setCanModerate(true);
$this->categoryPermMapper->expects($this->once())
->method('findByRoleId')
->with($roleId)
->willReturn([$perm1, $perm2]);
$response = $this->controller->getPermissions($roleId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertIsArray($data);
$this->assertCount(2, $data);
$this->assertEquals(1, $data[0]['categoryId']);
$this->assertTrue($data[0]['canView']);
$this->assertFalse($data[0]['canModerate']);
$this->assertEquals(2, $data[1]['categoryId']);
$this->assertTrue($data[1]['canModerate']);
}
public function testUpdatePermissionsSuccessfully(): void {
$roleId = 2;
$permissions = [
['categoryId' => 1, 'canView' => true, 'canModerate' => false],
['categoryId' => 2, 'canView' => true, 'canModerate' => true],
];
$role = $this->createRole($roleId, 'Moderator', true, false, true);
$this->roleMapper->expects($this->once())
->method('find')
->with($roleId)
->willReturn($role);
$this->categoryPermMapper->expects($this->once())
->method('deleteByRoleId')
->with($roleId);
$this->categoryPermMapper->expects($this->exactly(2))
->method('insert')
->willReturnCallback(function ($perm) use ($roleId) {
$this->assertEquals($roleId, $perm->getRoleId());
// Verify canPost and canReply are set based on canView
if ($perm->getCategoryId() === 1) {
$this->assertTrue($perm->getCanView());
$this->assertTrue($perm->getCanPost());
$this->assertTrue($perm->getCanReply());
$this->assertFalse($perm->getCanModerate());
} else {
$this->assertTrue($perm->getCanView());
$this->assertTrue($perm->getCanPost());
$this->assertTrue($perm->getCanReply());
$this->assertTrue($perm->getCanModerate());
}
return $perm;
});
$response = $this->controller->updatePermissions($roleId, $permissions);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['success']);
}
public function testUpdatePermissionsReturnsNotFoundWhenRoleDoesNotExist(): void {
$roleId = 999;
$permissions = [
['categoryId' => 1, 'canView' => true, 'canModerate' => false],
];
$this->roleMapper->expects($this->once())
->method('find')
->with($roleId)
->willThrowException(new DoesNotExistException('Role not found'));
$this->categoryPermMapper->expects($this->never())
->method('deleteByRoleId');
$this->categoryPermMapper->expects($this->never())
->method('insert');
$response = $this->controller->updatePermissions($roleId, $permissions);
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
$data = $response->getData();
$this->assertEquals(['error' => 'Role not found'], $data);
}
private function createRole(int $id, string $name, bool $canAccessAdminTools, bool $canEditRoles, bool $canEditCategories): Role {
$role = new Role();
$role->setId($id);
$role->setName($name);
$role->setCanAccessAdminTools($canAccessAdminTools);
$role->setCanEditRoles($canEditRoles);
$role->setCanEditCategories($canEditCategories);
$role->setCreatedAt(time());
return $role;
}
}

View File

@@ -222,9 +222,11 @@ class ThreadControllerTest extends TestCase {
$forumUser->setUserId($userId);
$forumUser->setPostCount(10);
// Mock user stats increment methods (void methods, no return value)
$this->userStatsMapper->method('incrementPostCount');
$this->userStatsMapper->method('incrementThreadCount');
// Mock user stats increment methods (first post doesn't count, only thread count increments)
$this->userStatsMapper->expects($this->never())
->method('incrementPostCount');
$this->userStatsMapper->expects($this->once())
->method('incrementThreadCount');
// Mock thread subscription
$this->userPreferencesService->method('getPreference')->willReturn(false);
@@ -261,7 +263,8 @@ class ThreadControllerTest extends TestCase {
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($thread) {
$this->assertEquals(1, $thread->getPostCount());
// First post doesn't count, so postCount should be 0
$this->assertEquals(0, $thread->getPostCount());
return $thread;
});
@@ -274,7 +277,8 @@ class ThreadControllerTest extends TestCase {
->method('update')
->willReturnCallback(function ($updatedCategory) {
$this->assertEquals(6, $updatedCategory->getThreadCount());
$this->assertEquals(21, $updatedCategory->getPostCount());
// First post doesn't count, so postCount stays at 20
$this->assertEquals(20, $updatedCategory->getPostCount());
return $updatedCategory;
});
@@ -498,6 +502,137 @@ class ThreadControllerTest extends TestCase {
$this->assertEquals(['error' => 'Thread not found'], $response->getData());
}
public function testByAuthorReturnsThreadsSuccessfully(): void {
$authorId = 'user1';
$limit = 50;
$offset = 0;
$thread1 = $this->createMockThread(1, 1, $authorId, 'Thread 1');
$thread2 = $this->createMockThread(2, 1, $authorId, 'Thread 2');
$threads = [$thread1, $thread2];
$enrichedAuthor = [
'userId' => $authorId,
'displayName' => 'Test User',
];
$this->threadMapper->expects($this->once())
->method('findByAuthorId')
->with($authorId, $limit, $offset)
->willReturn($threads);
$this->userService->expects($this->once())
->method('enrichUserData')
->with($authorId)
->willReturn($enrichedAuthor);
$this->threadEnrichmentService->expects($this->exactly(2))
->method('enrichThread')
->willReturnCallback(function ($thread, $author) {
$data = $thread->jsonSerialize();
$data['author'] = $author;
return $data;
});
$response = $this->controller->byAuthor($authorId, $limit, $offset);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertIsArray($data);
$this->assertCount(2, $data);
$this->assertEquals(1, $data[0]['id']);
$this->assertEquals(2, $data[1]['id']);
$this->assertEquals($enrichedAuthor, $data[0]['author']);
}
public function testSetLockedUpdatesThreadSuccessfully(): void {
$threadId = 1;
$thread = $this->createMockThread($threadId, 1, 'user1', 'Test Thread');
$thread->setIsLocked(false);
$this->threadMapper->expects($this->once())
->method('find')
->with($threadId)
->willReturn($thread);
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) {
$this->assertTrue($updatedThread->getIsLocked());
return $updatedThread;
});
$this->threadEnrichmentService->expects($this->once())
->method('enrichThread')
->willReturnCallback(function ($thread) {
return $thread->jsonSerialize();
});
$response = $this->controller->setLocked($threadId, true);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertEquals($threadId, $data['id']);
}
public function testSetLockedReturnsNotFoundWhenThreadDoesNotExist(): void {
$threadId = 999;
$this->threadMapper->expects($this->once())
->method('find')
->with($threadId)
->willThrowException(new DoesNotExistException('Thread not found'));
$response = $this->controller->setLocked($threadId, true);
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
$this->assertEquals(['error' => 'Thread not found'], $response->getData());
}
public function testSetPinnedUpdatesThreadSuccessfully(): void {
$threadId = 1;
$thread = $this->createMockThread($threadId, 1, 'user1', 'Test Thread');
$thread->setIsPinned(false);
$this->threadMapper->expects($this->once())
->method('find')
->with($threadId)
->willReturn($thread);
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) {
$this->assertTrue($updatedThread->getIsPinned());
return $updatedThread;
});
$this->threadEnrichmentService->expects($this->once())
->method('enrichThread')
->willReturnCallback(function ($thread) {
return $thread->jsonSerialize();
});
$response = $this->controller->setPinned($threadId, true);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertEquals($threadId, $data['id']);
}
public function testSetPinnedReturnsNotFoundWhenThreadDoesNotExist(): void {
$threadId = 999;
$this->threadMapper->expects($this->once())
->method('find')
->with($threadId)
->willThrowException(new DoesNotExistException('Thread not found'));
$response = $this->controller->setPinned($threadId, true);
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
$this->assertEquals(['error' => 'Thread not found'], $response->getData());
}
private function createMockThread(int $id, int $categoryId, string $authorId, string $title): Thread {
$thread = new Thread();
$thread->setId($id);

View File

@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace OCA\Forum\Tests\Controller;
use OCA\Forum\AppInfo\Application;
use OCA\Forum\Controller\ThreadSubscriptionController;
use OCA\Forum\Db\ThreadSubscription;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCP\AppFramework\Http;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class ThreadSubscriptionControllerTest extends TestCase {
private ThreadSubscriptionController $controller;
private ThreadSubscriptionMapper $subscriptionMapper;
private IUserSession $userSession;
private LoggerInterface $logger;
private IRequest $request;
protected function setUp(): void {
$this->request = $this->createMock(IRequest::class);
$this->subscriptionMapper = $this->createMock(ThreadSubscriptionMapper::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->controller = new ThreadSubscriptionController(
Application::APP_ID,
$this->request,
$this->subscriptionMapper,
$this->userSession,
$this->logger
);
}
public function testSubscribeSuccessfully(): void {
$threadId = 1;
$userId = 'user1';
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$subscription = $this->createMockSubscription(1, $userId, $threadId);
$this->subscriptionMapper->expects($this->once())
->method('subscribe')
->with($userId, $threadId)
->willReturn($subscription);
$response = $this->controller->subscribe($threadId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['success']);
$this->assertArrayHasKey('subscription', $data);
$this->assertEquals($userId, $data['subscription']['userId']);
$this->assertEquals($threadId, $data['subscription']['threadId']);
}
public function testSubscribeReturnsUnauthorizedWhenUserNotAuthenticated(): void {
$threadId = 1;
$this->userSession->method('getUser')->willReturn(null);
$response = $this->controller->subscribe($threadId);
$this->assertEquals(Http::STATUS_UNAUTHORIZED, $response->getStatus());
$this->assertEquals(['error' => 'User not authenticated'], $response->getData());
}
public function testUnsubscribeSuccessfully(): void {
$threadId = 1;
$userId = 'user1';
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$this->subscriptionMapper->expects($this->once())
->method('unsubscribe')
->with($userId, $threadId);
$response = $this->controller->unsubscribe($threadId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['success']);
}
public function testUnsubscribeReturnsUnauthorizedWhenUserNotAuthenticated(): void {
$threadId = 1;
$this->userSession->method('getUser')->willReturn(null);
$response = $this->controller->unsubscribe($threadId);
$this->assertEquals(Http::STATUS_UNAUTHORIZED, $response->getStatus());
$this->assertEquals(['error' => 'User not authenticated'], $response->getData());
}
public function testIsSubscribedReturnsTrue(): void {
$threadId = 1;
$userId = 'user1';
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$this->subscriptionMapper->expects($this->once())
->method('isUserSubscribed')
->with($userId, $threadId)
->willReturn(true);
$response = $this->controller->isSubscribed($threadId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['isSubscribed']);
}
public function testIsSubscribedReturnsFalse(): void {
$threadId = 1;
$userId = 'user1';
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$this->subscriptionMapper->expects($this->once())
->method('isUserSubscribed')
->with($userId, $threadId)
->willReturn(false);
$response = $this->controller->isSubscribed($threadId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertFalse($data['isSubscribed']);
}
public function testIsSubscribedReturnsUnauthorizedWhenUserNotAuthenticated(): void {
$threadId = 1;
$this->userSession->method('getUser')->willReturn(null);
$response = $this->controller->isSubscribed($threadId);
$this->assertEquals(Http::STATUS_UNAUTHORIZED, $response->getStatus());
$this->assertEquals(['error' => 'User not authenticated'], $response->getData());
}
public function testGetUserSubscriptionsReturnsSubscriptionsSuccessfully(): void {
$userId = 'user1';
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$subscription1 = $this->createMockSubscription(1, $userId, 1);
$subscription2 = $this->createMockSubscription(2, $userId, 2);
$subscriptions = [$subscription1, $subscription2];
$this->subscriptionMapper->expects($this->once())
->method('findByUserId')
->with($userId)
->willReturn($subscriptions);
$response = $this->controller->getUserSubscriptions();
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertIsArray($data);
$this->assertCount(2, $data);
$this->assertEquals(1, $data[0]['id']);
$this->assertEquals(2, $data[1]['id']);
$this->assertEquals($userId, $data[0]['userId']);
$this->assertEquals(1, $data[0]['threadId']);
}
public function testGetUserSubscriptionsHandlesEmptySubscriptions(): void {
$userId = 'user1';
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$this->subscriptionMapper->expects($this->once())
->method('findByUserId')
->with($userId)
->willReturn([]);
$response = $this->controller->getUserSubscriptions();
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertIsArray($data);
$this->assertCount(0, $data);
}
public function testGetUserSubscriptionsReturnsUnauthorizedWhenUserNotAuthenticated(): void {
$this->userSession->method('getUser')->willReturn(null);
$response = $this->controller->getUserSubscriptions();
$this->assertEquals(Http::STATUS_UNAUTHORIZED, $response->getStatus());
$this->assertEquals(['error' => 'User not authenticated'], $response->getData());
}
private function createMockSubscription(int $id, string $userId, int $threadId): ThreadSubscription {
$subscription = new ThreadSubscription();
$subscription->setId($id);
$subscription->setUserId($userId);
$subscription->setThreadId($threadId);
$subscription->setCreatedAt(time());
return $subscription;
}
}

View File

@@ -161,17 +161,18 @@ class PermissionServiceTest extends TestCase {
$categoryId = 1;
$permission = 'canPost';
$userRole = $this->createUserRole(1, $userId, 1);
$categoryPerm = $this->createCategoryPerm(1, $categoryId, 1, true, true, true, false);
// Using role ID 3 (User) instead of 1 (Admin) to test normal permission check
$userRole = $this->createUserRole(1, $userId, 3);
$categoryPerm = $this->createCategoryPerm(1, $categoryId, 3, true, true, true, false);
$this->userRoleMapper->expects($this->once())
$this->userRoleMapper->expects($this->exactly(2))
->method('findByUserId')
->with($userId)
->willReturn([$userRole]);
$this->categoryPermMapper->expects($this->once())
->method('findByCategoryAndRole')
->with($categoryId, 1)
->with($categoryId, 3)
->willReturn($categoryPerm);
$result = $this->service->hasCategoryPermission($userId, $categoryId, $permission);
@@ -184,17 +185,18 @@ class PermissionServiceTest extends TestCase {
$categoryId = 1;
$permission = 'canModerate';
$userRole = $this->createUserRole(1, $userId, 1);
$categoryPerm = $this->createCategoryPerm(1, $categoryId, 1, true, true, true, false);
// Using role ID 2 (Moderator) instead of 1 (Admin) to test normal permission check
$userRole = $this->createUserRole(1, $userId, 2);
$categoryPerm = $this->createCategoryPerm(1, $categoryId, 2, true, true, true, false);
$this->userRoleMapper->expects($this->once())
$this->userRoleMapper->expects($this->exactly(2))
->method('findByUserId')
->with($userId)
->willReturn([$userRole]);
$this->categoryPermMapper->expects($this->once())
->method('findByCategoryAndRole')
->with($categoryId, 1)
->with($categoryId, 2)
->willReturn($categoryPerm);
$result = $this->service->hasCategoryPermission($userId, $categoryId, $permission);
@@ -207,6 +209,29 @@ class PermissionServiceTest extends TestCase {
$categoryId = 1;
$permission = 'canPost';
$userRole = $this->createUserRole(1, $userId, 3); // Non-admin role
$this->userRoleMapper->expects($this->exactly(2))
->method('findByUserId')
->with($userId)
->willReturn([$userRole]);
$this->categoryPermMapper->expects($this->once())
->method('findByCategoryAndRole')
->with($categoryId, 3)
->willThrowException(new DoesNotExistException('Permission not found'));
$result = $this->service->hasCategoryPermission($userId, $categoryId, $permission);
$this->assertFalse($result);
}
public function testHasCategoryPermissionReturnsTrueForAdminRoleRegardlessOfPermissions(): void {
$userId = 'admin1';
$categoryId = 1;
$permission = 'canModerate';
// User has Admin role (ID 1)
$userRole = $this->createUserRole(1, $userId, 1);
$this->userRoleMapper->expects($this->once())
@@ -214,14 +239,36 @@ class PermissionServiceTest extends TestCase {
->with($userId)
->willReturn([$userRole]);
$this->categoryPermMapper->expects($this->once())
->method('findByCategoryAndRole')
->with($categoryId, 1)
->willThrowException(new DoesNotExistException('Permission not found'));
// Should not even check category permissions for Admin
$this->categoryPermMapper->expects($this->never())
->method('findByCategoryAndRole');
$result = $this->service->hasCategoryPermission($userId, $categoryId, $permission);
$this->assertFalse($result);
$this->assertTrue($result);
}
public function testHasCategoryPermissionReturnsTrueForAdminEvenWithOtherRoles(): void {
$userId = 'admin1';
$categoryId = 1;
$permission = 'canView';
// User has both Admin (ID 1) and User (ID 3) roles
$userRole1 = $this->createUserRole(1, $userId, 1);
$userRole2 = $this->createUserRole(2, $userId, 3);
$this->userRoleMapper->expects($this->once())
->method('findByUserId')
->with($userId)
->willReturn([$userRole1, $userRole2]);
// Should not check category permissions for Admin
$this->categoryPermMapper->expects($this->never())
->method('findByCategoryAndRole');
$result = $this->service->hasCategoryPermission($userId, $categoryId, $permission);
$this->assertTrue($result);
}
public function testGetCategoryIdFromThreadReturnsCorrectId(): void {

171
vendor-bin/cs-fixer/composer.lock generated Normal file
View File

@@ -0,0 +1,171 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "59bdbac023efd7059e30cfd98dc00b94",
"packages": [],
"packages-dev": [
{
"name": "kubawerlos/php-cs-fixer-custom-fixers",
"version": "v3.35.1",
"source": {
"type": "git",
"url": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers.git",
"reference": "2a35f80ae24ca77443a7af1599c3a3db1b6bd395"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/2a35f80ae24ca77443a7af1599c3a3db1b6bd395",
"reference": "2a35f80ae24ca77443a7af1599c3a3db1b6bd395",
"shasum": ""
},
"require": {
"ext-filter": "*",
"ext-tokenizer": "*",
"friendsofphp/php-cs-fixer": "^3.87",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.6.24 || ^10.5.51 || ^11.5.32"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpCsFixerCustomFixers\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Kuba Werłos",
"email": "werlos@gmail.com"
}
],
"description": "A set of custom fixers for PHP CS Fixer",
"support": {
"issues": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/issues",
"source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.35.1"
},
"funding": [
{
"url": "https://github.com/kubawerlos",
"type": "github"
}
],
"time": "2025-09-28T18:43:35+00:00"
},
{
"name": "nextcloud/coding-standard",
"version": "v1.4.0",
"source": {
"type": "git",
"url": "https://github.com/nextcloud/coding-standard.git",
"reference": "8e06808c1423e9208d63d1bd205b9a38bd400011"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/8e06808c1423e9208d63d1bd205b9a38bd400011",
"reference": "8e06808c1423e9208d63d1bd205b9a38bd400011",
"shasum": ""
},
"require": {
"kubawerlos/php-cs-fixer-custom-fixers": "^3.22",
"php": "^8.0",
"php-cs-fixer/shim": "^3.17"
},
"type": "library",
"autoload": {
"psr-4": {
"Nextcloud\\CodingStandard\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christoph Wurst",
"email": "christoph@winzerhof-wurst.at"
}
],
"description": "Nextcloud coding standards for the php cs fixer",
"keywords": [
"dev"
],
"support": {
"issues": "https://github.com/nextcloud/coding-standard/issues",
"source": "https://github.com/nextcloud/coding-standard/tree/v1.4.0"
},
"time": "2025-06-19T12:27:27+00:00"
},
{
"name": "php-cs-fixer/shim",
"version": "v3.90.0",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/shim.git",
"reference": "db628db551759424b1ba511aef3b0ff0550044f6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/db628db551759424b1ba511aef3b0ff0550044f6",
"reference": "db628db551759424b1ba511aef3b0ff0550044f6",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-tokenizer": "*",
"php": "^7.4 || ^8.0"
},
"replace": {
"friendsofphp/php-cs-fixer": "self.version"
},
"suggest": {
"ext-dom": "For handling output formats in XML",
"ext-mbstring": "For handling non-UTF8 characters."
},
"bin": [
"php-cs-fixer",
"php-cs-fixer.phar"
],
"type": "application",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Dariusz Rumiński",
"email": "dariusz.ruminski@gmail.com"
}
],
"description": "A tool to automatically fix PHP code style",
"support": {
"issues": "https://github.com/PHP-CS-Fixer/shim/issues",
"source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.90.0"
},
"time": "2025-11-20T15:15:37+00:00"
}
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"platform-overrides": {
"php": "8.1"
},
"plugin-api-version": "2.6.0"
}

266
vendor-bin/openapi-extractor/composer.lock generated Normal file
View File

@@ -0,0 +1,266 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "cc4604e4c3d0f17f3feec0fe94971ff6",
"packages": [],
"packages-dev": [
{
"name": "adhocore/cli",
"version": "v1.9.4",
"source": {
"type": "git",
"url": "https://github.com/adhocore/php-cli.git",
"reference": "474dc3d7ab139796be98b104d891476e3916b6f4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/adhocore/php-cli/zipball/474dc3d7ab139796be98b104d891476e3916b6f4",
"reference": "474dc3d7ab139796be98b104d891476e3916b6f4",
"shasum": ""
},
"require": {
"php": ">=8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.0"
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Ahc\\Cli\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jitendra Adhikari",
"email": "jiten.adhikary@gmail.com"
}
],
"description": "Command line interface library for PHP",
"keywords": [
"argument-parser",
"argv-parser",
"cli",
"cli-action",
"cli-app",
"cli-color",
"cli-option",
"cli-writer",
"command",
"console",
"console-app",
"php-cli",
"php8",
"stream-input",
"stream-output"
],
"support": {
"issues": "https://github.com/adhocore/php-cli/issues",
"source": "https://github.com/adhocore/php-cli/tree/v1.9.4"
},
"funding": [
{
"url": "https://paypal.me/ji10",
"type": "custom"
},
{
"url": "https://github.com/adhocore",
"type": "github"
}
],
"time": "2025-05-11T13:23:54+00:00"
},
{
"name": "nextcloud/openapi-extractor",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/nextcloud/openapi-extractor.git",
"reference": "fc40e144aa0d7bdd388a20ded6dc7681f037a18e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nextcloud/openapi-extractor/zipball/fc40e144aa0d7bdd388a20ded6dc7681f037a18e",
"reference": "fc40e144aa0d7bdd388a20ded6dc7681f037a18e",
"shasum": ""
},
"require": {
"adhocore/cli": "^1.7",
"ext-simplexml": "*",
"nikic/php-parser": "^5.0",
"php": "^8.1",
"phpstan/phpdoc-parser": "^2.1"
},
"require-dev": {
"nextcloud/coding-standard": "^1.2",
"nextcloud/ocp": "dev-master",
"rector/rector": "^2.0"
},
"default-branch": true,
"bin": [
"bin/generate-spec",
"bin/merge-specs"
],
"type": "library",
"autoload": {
"psr-4": {
"OpenAPIExtractor\\": "src"
}
},
"scripts": {
"lint": [
"find . -name \\*.php -not -path './tests/*' -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l"
],
"cs:check": [
"php-cs-fixer fix --dry-run --diff"
],
"cs:fix": [
"php-cs-fixer fix"
],
"test:unit": [
"cd tests && ../bin/generate-spec"
],
"rector": [
"rector && composer cs:fix"
]
},
"license": [
"AGPL-3.0-or-later"
],
"description": "A tool for extracting OpenAPI specifications from Nextcloud source code",
"support": {
"source": "https://github.com/nextcloud/openapi-extractor/tree/main",
"issues": "https://github.com/nextcloud/openapi-extractor/issues"
},
"time": "2025-11-17T10:11:48+00:00"
},
{
"name": "nikic/php-parser",
"version": "v5.6.2",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
"reference": "3a454ca033b9e06b63282ce19562e892747449bb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb",
"reference": "3a454ca033b9e06b63282ce19562e892747449bb",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-json": "*",
"ext-tokenizer": "*",
"php": ">=7.4"
},
"require-dev": {
"ircmaxell/php-yacc": "^0.0.7",
"phpunit/phpunit": "^9.0"
},
"bin": [
"bin/php-parse"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.x-dev"
}
},
"autoload": {
"psr-4": {
"PhpParser\\": "lib/PhpParser"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Nikita Popov"
}
],
"description": "A PHP parser written in PHP",
"keywords": [
"parser",
"php"
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
"source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2"
},
"time": "2025-10-21T19:32:17+00:00"
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495",
"reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"require-dev": {
"doctrine/annotations": "^2.0",
"nikic/php-parser": "^5.3.0",
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6",
"symfony/process": "^5.2"
},
"type": "library",
"autoload": {
"psr-4": {
"PHPStan\\PhpDocParser\\": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0"
},
"time": "2025-08-30T15:50:23+00:00"
}
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {
"nextcloud/openapi-extractor": 20
},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"platform-overrides": {
"php": "8.1"
},
"plugin-api-version": "2.6.0"
}

1691
vendor-bin/phpunit/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

2127
vendor-bin/psalm/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
0.9.0
0.10.0