Merge master

This commit is contained in:
Maximilian Harz 2026-02-01 22:14:53 +01:00
commit 13f1374c60
107 changed files with 8357 additions and 6969 deletions

View File

@ -61,7 +61,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
- name: Log in to the Container registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}

View File

@ -17,6 +17,10 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
with:
fetch-depth: 0 # Fetch full History for changelog
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.0.3
with:
node-version-file: '.nvmrc'
- name: Setup env
run: |
echo "VERSION=$(node -p -e "require('./package.json').version")" >> $GITHUB_ENV
@ -57,6 +61,10 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
with:
fetch-depth: 0 # Fetch full History for changelog
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.0.3
with:
node-version-file: '.nvmrc'
- name: Setup env
run: |
echo "VERSION=$(node -p -e "require('./package.json').version")" >> $GITHUB_ENV

View File

@ -37,7 +37,7 @@ jobs:
- name: Cache docker images
id: cache-neo4j
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
with:
path: /tmp/neo4j.tar
key: ${{ github.run_id }}-backend-neo4j-cache
@ -58,7 +58,7 @@ jobs:
- name: Cache docker images
id: cache-backend
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
with:
path: /tmp/backend.tar
key: ${{ github.run_id }}-backend-cache
@ -72,6 +72,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.0.3
with:
node-version-file: 'backend/.nvmrc'
- name: backend | Lint
run: cd backend && yarn && yarn run lint
@ -87,14 +92,14 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
- name: Restore Neo4J cache
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
with:
path: /tmp/neo4j.tar
key: ${{ github.run_id }}-backend-neo4j-cache
fail-on-cache-miss: true
- name: Restore Backend cache
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
with:
path: /tmp/backend.tar
key: ${{ github.run_id }}-backend-cache

View File

@ -31,7 +31,7 @@ jobs:
docker compose -f docker-compose.yml -f docker-compose.test.yml down
- name: Cache docker images
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
with:
path: |
/tmp/backend.tar
@ -59,7 +59,7 @@ jobs:
docker save "ghcr.io/ocelot-social-community/ocelot-social/webapp:test" > /tmp/webapp.tar
- name: Cache docker image
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
with:
path: /tmp/webapp.tar
key: ${{ github.run_id }}-e2e-webapp-cache
@ -77,7 +77,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.4.0
with:
node-version-file: 'backend/.tool-versions'
node-version-file: 'backend/.nvmrc'
cache: 'yarn'
- name: Copy env files
@ -87,7 +87,8 @@ jobs:
- name: Install cypress requirements
run: |
wget --no-verbose -O /opt/cucumber-json-formatter "https://github.com/cucumber/json-formatter/releases/download/v19.0.0/cucumber-json-formatter-linux-386"
sudo wget --no-verbose -O /opt/cucumber-json-formatter "https://github.com/cucumber/json-formatter/releases/download/v19.0.0/cucumber-json-formatter-linux-386"
sudo chmod +x /opt/cucumber-json-formatter
cd backend
yarn install
yarn build
@ -96,7 +97,7 @@ jobs:
- name: Cache docker image
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
with:
path: |
/opt/cucumber-json-formatter
@ -125,11 +126,11 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.4.0
with:
node-version-file: 'backend/.tool-versions'
node-version-file: 'backend/.nvmrc'
cache: 'yarn'
- name: Restore cypress cache
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
with:
path: |
/opt/cucumber-json-formatter
@ -139,7 +140,7 @@ jobs:
restore-keys: ${{ github.run_id }}-e2e-cypress
- name: Restore backend environment cache
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
with:
path: |
/tmp/backend.tar
@ -150,7 +151,7 @@ jobs:
key: ${{ github.run_id }}-e2e-backend-environment-cache
- name: Restore webapp cache
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
with:
path: /tmp/webapp.tar
key: ${{ github.run_id }}-e2e-webapp-cache

View File

@ -30,6 +30,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.0.3
with:
node-version-file: 'webapp/.nvmrc'
- name: Check translation files
run: |
scripts/translations/sort.sh
@ -50,7 +55,7 @@ jobs:
docker save "ocelotsocialnetwork/webapp:test" > /tmp/webapp.tar
- name: Cache docker image
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
with:
path: /tmp/webapp.tar
key: ${{ github.run_id }}-webapp-cache
@ -64,6 +69,11 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.0.3
with:
node-version-file: 'webapp/.nvmrc'
- name: webapp | Lint
run: cd webapp && yarn && yarn run lint
@ -79,7 +89,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
- name: Restore webapp cache
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v4.0.2
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
with:
path: /tmp/webapp.tar
key: ${{ github.run_id }}-webapp-cache

2
.nvmrc
View File

@ -1 +1 @@
v24.2.0
v25.3.0

View File

@ -1 +0,0 @@
nodejs 20.12.1

View File

@ -48,3 +48,4 @@ IMAGOR_SECRET=mysecret
CATEGORIES_ACTIVE=false
MAX_PINNED_POSTS=1
MAX_GROUP_PINNED_POSTS=1

View File

@ -40,3 +40,4 @@ IMAGOR_SECRET=mysecret
CATEGORIES_ACTIVE=false
MAX_PINNED_POSTS=1
MAX_GROUP_PINNED_POSTS=1

View File

@ -14,7 +14,6 @@ module.exports = {
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:promise/recommended',
'plugin:security/recommended-legacy',
'plugin:@eslint-community/eslint-comments/recommended',
'prettier',
],
@ -175,6 +174,10 @@ module.exports = {
'@eslint-community/eslint-comments/require-description': 'off',
},
overrides: [
{
files: ['*.js', '*.cjs', '*.ts', '*.tsx'],
extends: ['plugin:security/recommended-legacy'],
},
// only for ts files
{
files: ['*.ts', '*.tsx'],
@ -228,5 +231,33 @@ module.exports = {
files: ['*.json', '*.json5', '*.jsonc'],
parser: 'jsonc-eslint-parser',
},
{
files: ['*.graphql', '*.gql'],
parser: '@graphql-eslint/eslint-plugin',
plugins: ['@graphql-eslint'],
extends: ['plugin:@graphql-eslint/schema-recommended'],
rules: {
'@graphql-eslint/description-style': ['error', { style: 'inline' }],
'@graphql-eslint/require-description': 'off',
'@graphql-eslint/naming-convention': 'off',
'@graphql-eslint/strict-id-in-types': 'off',
'@graphql-eslint/no-typename-prefix': 'off',
// incompatible: `depends on a GraphQL validation rule "XXX" but it's not available in the "graphql" version you are using. Skipping…`
'@graphql-eslint/known-directives': 'off',
'@graphql-eslint/known-argument-names': 'off',
'@graphql-eslint/known-type-names': 'off',
'@graphql-eslint/lone-schema-definition': 'off',
'@graphql-eslint/provided-required-arguments': 'off',
'@graphql-eslint/unique-directive-names': 'off',
'@graphql-eslint/unique-directive-names-per-location': 'off',
'@graphql-eslint/unique-field-definition-names': 'off',
'@graphql-eslint/unique-operation-types': 'off',
'@graphql-eslint/unique-type-names': 'off',
},
parserOptions: {
schema: './src/graphql/types/**/*.gql',
assumeValid: true,
},
},
],
}

View File

@ -1 +1 @@
v24.2.0
v25.3.0

View File

@ -1 +0,0 @@
nodejs 24.2.0

View File

@ -1,4 +1,4 @@
FROM node:25.4.0-alpine AS base
FROM node:25.5.0-alpine AS base
LABEL org.label-schema.name="ocelot.social:backend"
LABEL org.label-schema.description="Backend of the Social Network Software ocelot.social"
LABEL org.label-schema.usage="https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/README.md"

View File

@ -19,18 +19,16 @@ Wait a little until your backend is up and running at [http://localhost:4000/](h
## Installation without Docker
For the local installation you need a recent version of
[Node](https://nodejs.org/en/) (>= `v16.19.0`). We are using
`v24.2.0` and therefore we recommend to use the same version
([see](https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4082)
some known problems with more recent node versions). You can use the
[Node](https://nodejs.org/en/). We are using
`v25.3.0` and therefore we recommend to use the same version. You can use the
[node version manager](https://github.com/nvm-sh/nvm) `nvm` to switch
between different local Node versions:
```sh
# install Node
# install Node using '.nvmrc' file
$ cd backend
$ nvm install v24.2.0
$ nvm use v24.2.0
$ nvm install
$ nvm use
```
Install node dependencies with [yarn](https://yarnpkg.com/en/):

View File

@ -12,7 +12,7 @@
"build": "tsc && tsc-alias && ./scripts/build.copy.files.sh",
"dev": "nodemon --exec ts-node --require tsconfig-paths/register src/index.ts -e js,ts,gql",
"dev:debug": "nodemon --exec node --inspect=0.0.0.0:9229 build/src/index.js -e js,ts,gql",
"lint": "eslint --max-warnings=0 --report-unused-disable-directives --ext .js,.ts,.cjs,.json,.json5,.jsonc .",
"lint": "eslint --max-warnings=0 --report-unused-disable-directives --ext .js,.ts,.cjs,.json,.json5,.jsonc,.graphql,.gql .",
"test": "cross-env NODE_ENV=test NODE_OPTIONS=--max-old-space-size=8192 jest --runInBand --coverage --forceExit --detectOpenHandles",
"db:reset": "ts-node --require tsconfig-paths/register src/db/reset.ts",
"db:reset:withmigrations": "ts-node --require tsconfig-paths/register src/db/reset-with-migrations.ts",
@ -23,14 +23,16 @@
"db:data:categories": "ts-node --require tsconfig-paths/register src/db/categories.ts",
"db:migrate": "migrate --compiler 'ts:./src/db/compiler.ts' --migrations-dir ./src/db/migrations --store ./src/db/migrate/store.ts",
"db:migrate:create": "migrate --compiler 'ts:./src/db/compiler.ts' --migrations-dir ./src/db/migrations --template-file ./src/db/migrate/template.ts --date-format 'yyyymmddHHmmss' create",
"db:func:disable:notifications": "ts-node --require tsconfig-paths/register src/db/disable-notifications.ts",
"prod:migrate": "migrate --migrations-dir ./build/src/db/migrations --store ./build/src/db/migrate/store.js",
"prod:db:data:branding": "node build/src/db/data-branding.js",
"prod:db:data:categories": "node build/src/db/categories.js",
"prod:db:data:admin": "node build/src/db/admin.js"
"prod:db:data:admin": "node build/src/db/admin.js",
"prod:db:func:disable:notifications": "node build/src/db/disable-notifications.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.975.0",
"@aws-sdk/lib-storage": "^3.975.0",
"@aws-sdk/client-s3": "^3.980.0",
"@aws-sdk/lib-storage": "^3.980.0",
"@sentry/node": "^5.30.0",
"@types/mime-types": "^3.0.1",
"apollo-server": "~2.14.2",
@ -41,7 +43,7 @@
"cross-env": "~10.1.0",
"dotenv": "~17.0.1",
"email-templates": "^12.0.3",
"express": "^5.2.1",
"express": "^4.22.1",
"graphql": "^14.6.0",
"graphql-middleware": "~6.1.35",
"graphql-middleware-sentry": "^3.2.1",
@ -77,7 +79,7 @@
"minimatch": "^10.1.1",
"mustache": "^4.2.0",
"neo4j-driver": "^4.4.11",
"neo4j-graphql-js": "^2.11.5",
"neo4j-graphql-js": "2.11.5",
"neode": "^0.4.9",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.12",
@ -95,11 +97,12 @@
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
"@faker-js/faker": "9.9.0",
"@graphql-eslint/eslint-plugin": "^3.20.1",
"@types/email-templates": "^10.0.4",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "~8.5.1",
"@types/lodash": "^4.17.23",
"@types/node": "^25.0.10",
"@types/node": "^25.1.0",
"@types/request": "^2.48.13",
"@types/slug": "^5.0.9",
"@types/uuid": "~9.0.1",
@ -135,7 +138,8 @@
"**/strip-ansi": "6.0.1",
"**/string-width": "4.2.0",
"**/wrap-ansi": "7.0.0",
"**/jwa": "^2.0.1"
"**/jwa": "^2.0.1",
"**/@types/express": "4.17.25"
},
"engines": {
"node": ">=20.12.1"

View File

@ -138,6 +138,9 @@ const options = {
MAX_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_PINNED_POSTS))
? 1
: Number(process.env.MAX_PINNED_POSTS),
MAX_GROUP_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_GROUP_PINNED_POSTS))
? 1
: Number(process.env.MAX_GROUP_PINNED_POSTS),
}
const language = {

View File

@ -0,0 +1,61 @@
import databaseContext from '@context/database'
const run = async () => {
const args = process.argv.slice(2)
if (args.length !== 1) {
// eslint-disable-next-line no-console
console.error('Usage: yarn run db:func:disable-notifications <email>')
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
const email = args[0]
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
// eslint-disable-next-line no-console
console.error('Error: Invalid email address format')
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
const { write } = databaseContext()
const result = (
await write({
query: `
MATCH (:EmailAddress {email: $email})-[:BELONGS_TO]->(user:User)
SET user.emailNotificationsFollowingUsers = false
SET user.emailNotificationsPostInGroup = false
SET user.emailNotificationsCommentOnObservedPost = false
SET user.emailNotificationsMention = false
SET user.emailNotificationsChatMessage = false
SET user.emailNotificationsGroupMemberJoined = false
SET user.emailNotificationsGroupMemberLeft = false
SET user.emailNotificationsGroupMemberRemoved = false
SET user.emailNotificationsGroupMemberRoleChanged = false
RETURN toString(count(user)) as count
`,
variables: {
email,
},
})
).records[0].get('count') as string
if (result !== '1') {
// eslint-disable-next-line no-console
console.error(`User with email address ${email} not found`)
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
// eslint-disable-next-line no-console
console.log(`Notifications for User with email address ${email} disabled`)
// eslint-disable-next-line n/no-process-exit
process.exit(0)
}
void (async function () {
await run()
})()

View File

@ -58,6 +58,7 @@ export default {
},
},
pinned: { type: 'boolean', default: null, valid: [null, true] },
groupPinned: { type: 'boolean', default: null, valid: [null, true] },
postType: { type: 'string', default: 'Article', valid: ['Article', 'Event'] },
observes: {
type: 'relationship',

View File

@ -2,7 +2,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import path from 'node:path'
import Email from 'email-templates'
@ -94,8 +93,8 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
: notification?.from?.title,
postUrl: new URL(
notification?.from?.__typename === 'Comment'
? `/post/${notification?.from?.post?.id}/${notification?.from?.post?.slug}`
: `/post/${notification?.from?.id}/${notification?.from?.slug}`,
? `/post/${encodeURIComponent(notification?.from?.post?.id)}/${encodeURIComponent(notification?.from?.post?.slug)}`
: `/post/${encodeURIComponent(notification?.from?.id)}/${encodeURIComponent(notification?.from?.slug)}`,
CONFIG.CLIENT_URI,
),
postAuthorName:
@ -106,7 +105,7 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
notification?.from?.__typename === 'Comment'
? undefined
: new URL(
`profile/${notification?.from?.author?.id}/${notification?.from?.author?.slug}`,
`profile/${encodeURIComponent(notification?.from?.author?.id)}/${encodeURIComponent(notification?.from?.author?.slug)}`,
CONFIG.CLIENT_URI,
),
commenterName:
@ -116,14 +115,14 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
commenterUrl:
notification?.from?.__typename === 'Comment'
? new URL(
`/profile/${notification?.from?.author?.id}/${notification?.from?.author?.slug}`,
`/profile/${encodeURIComponent(notification?.from?.author?.id)}/${encodeURIComponent(notification?.from?.author?.slug)}`,
CONFIG.CLIENT_URI,
)
: undefined,
commentUrl:
notification?.from?.__typename === 'Comment'
? new URL(
`/post/${notification?.from?.post?.id}/${notification?.from?.post?.slug}#commentId-${notification?.from?.id}`,
`/post/${encodeURIComponent(notification?.from?.post?.id)}/${encodeURIComponent(notification?.from?.post?.slug)}#commentId-${encodeURIComponent(notification?.from?.id)}`,
CONFIG.CLIENT_URI,
)
: undefined,
@ -132,7 +131,7 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
groupUrl:
notification?.from?.__typename === 'Group'
? new URL(
`/groups/${notification?.from?.id}/${notification?.from?.slug}`,
`/groups/${encodeURIComponent(notification?.from?.id)}/${encodeURIComponent(notification?.from?.slug)}`,
CONFIG.CLIENT_URI,
)
: undefined,
@ -143,7 +142,7 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
groupRelatedUserUrl:
notification?.from?.__typename === 'Group'
? new URL(
`/profile/${notification?.relatedUser?.id}/${notification?.relatedUser?.slug}`,
`/profile/${encodeURIComponent(notification?.relatedUser?.id)}/${encodeURIComponent(notification?.relatedUser?.slug)}`,
CONFIG.CLIENT_URI,
)
: undefined,
@ -177,7 +176,10 @@ export const sendChatMessageMail = async (
locale: recipientUser.locale,
name: recipientUser.name,
chattingUser: senderUser.name,
chattingUserUrl: new URL(`/profile/${senderUser.id}/${senderUser.slug}`, CONFIG.CLIENT_URI),
chattingUserUrl: new URL(
`/profile/${encodeURIComponent(senderUser.id)}/${encodeURIComponent(senderUser.slug)}`,
CONFIG.CLIENT_URI,
),
chatUrl: new URL('/chat', CONFIG.CLIENT_URI),
},
})

View File

@ -3,10 +3,14 @@ import gql from 'graphql-tag'
export const ChangeGroupMemberRole = gql`
mutation ($groupId: ID!, $userId: ID!, $roleInGroup: GroupMemberRole!) {
ChangeGroupMemberRole(groupId: $groupId, userId: $userId, roleInGroup: $roleInGroup) {
id
name
slug
myRoleInGroup
user {
id
name
slug
}
membership {
role
}
}
}
`

View File

@ -3,10 +3,14 @@ import gql from 'graphql-tag'
export const GroupMembers = gql`
query GroupMembers($id: ID!) {
GroupMembers(id: $id) {
id
name
slug
myRoleInGroup
user {
id
name
slug
}
membership {
role
}
}
}
`

View File

@ -3,10 +3,14 @@ import gql from 'graphql-tag'
export const JoinGroup = gql`
mutation ($groupId: ID!, $userId: ID!) {
JoinGroup(groupId: $groupId, userId: $userId) {
id
name
slug
myRoleInGroup
user {
id
name
slug
}
membership {
role
}
}
}
`

View File

@ -3,10 +3,14 @@ import gql from 'graphql-tag'
export const LeaveGroup = gql`
mutation ($groupId: ID!, $userId: ID!) {
LeaveGroup(groupId: $groupId, userId: $userId) {
id
name
slug
myRoleInGroup
user {
id
name
slug
}
membership {
role
}
}
}
`

View File

@ -3,10 +3,14 @@ import gql from 'graphql-tag'
export const RemoveUserFromGroup = gql`
mutation ($groupId: ID!, $userId: ID!) {
RemoveUserFromGroup(groupId: $groupId, userId: $userId) {
id
name
slug
myRoleInGroup
user {
id
name
slug
}
membership {
role
}
}
}
`

View File

@ -0,0 +1,25 @@
import gql from 'graphql-tag'
export const pinGroupPost = gql`
mutation ($id: ID!) {
pinGroupPost(id: $id) {
id
title
content
author {
name
slug
}
pinnedBy {
id
name
role
}
createdAt
updatedAt
pinnedAt
pinned
groupPinned
}
}
`

View File

@ -11,6 +11,7 @@ export const profilePagePosts = gql`
id
title
content
groupPinned
}
}
`

View File

@ -0,0 +1,25 @@
import gql from 'graphql-tag'
export const unpinGroupPost = gql`
mutation ($id: ID!) {
unpinGroupPost(id: $id) {
id
title
content
author {
name
slug
}
pinnedBy {
id
name
role
}
createdAt
updatedAt
pinned
pinnedAt
groupPinned
}
}
`

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { GraphQLUpload } from 'graphql-upload'
export default {

View File

@ -130,10 +130,13 @@ export const attachments = (config: S3Config) => {
const { upload } = fileInput
if (!upload) throw new UserInputError('Cannot find attachment for given resource')
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const uploadFile = await upload
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
const { name: fileName, ext } = path.parse(uploadFile.filename)
const uniqueFilename = `${uuid()}-${slug(fileName)}${ext}`
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const url = await s3.uploadFile({
...uploadFile,
uniqueFilename,

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import fs from 'node:fs'
import path from 'node:path'

View File

@ -2,21 +2,20 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '@db/factories'
import { getDriver, getNeode } from '@db/neo4j'
import { followUser } from '@graphql/queries/followUser'
import { unfollowUser } from '@graphql/queries/unfollowUser'
import { User } from '@graphql/queries/User'
import createServer from '@src/server'
import { createApolloTestSetup } from '@root/test/helpers'
import type { ApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
const driver = getDriver()
const neode = getNeode()
let query
let mutate
let authenticatedUser
let authenticatedUser: Context['user']
const context = () => ({ authenticatedUser })
let mutate: ApolloTestSetup['mutate']
let query: ApolloTestSetup['query']
let database: ApolloTestSetup['database']
let server: ApolloTestSetup['server']
let user1
let user2
@ -24,26 +23,18 @@ let variables
beforeAll(async () => {
await cleanDatabase()
const { server } = createServer({
context: () => ({
driver,
neode,
user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
}),
})
const testClient = createTestClient(server)
query = testClient.query
mutate = testClient.mutate
const apolloSetup = createApolloTestSetup({ context })
mutate = apolloSetup.mutate
query = apolloSetup.query
database = apolloSetup.database
server = apolloSetup.server
})
afterAll(async () => {
await cleanDatabase()
await driver.close()
void server.stop()
void database.driver.close()
database.neode.close()
})
beforeEach(async () => {
@ -118,7 +109,7 @@ describe('follow', () => {
mutation: followUser,
variables,
})
const relation = await neode.cypher(
const relation = await database.neode.cypher(
'MATCH (user:User {id: $id})-[relationship:FOLLOWS]->(followed:User) WHERE relationship.createdAt IS NOT NULL RETURN relationship',
{ id: 'u1' },
)

View File

@ -891,8 +891,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
JoinGroup: {
id: 'owner-of-closed-group',
myRoleInGroup: 'usual',
user: {
id: 'owner-of-closed-group',
},
membership: {
role: 'usual',
},
},
},
errors: undefined,
@ -914,8 +918,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
JoinGroup: {
id: 'current-user',
myRoleInGroup: 'owner',
user: {
id: 'current-user',
},
membership: {
role: 'owner',
},
},
},
errors: undefined,
@ -939,8 +947,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
JoinGroup: {
id: 'current-user',
myRoleInGroup: 'pending',
user: {
id: 'current-user',
},
membership: {
role: 'pending',
},
},
},
errors: undefined,
@ -962,8 +974,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
JoinGroup: {
id: 'owner-of-closed-group',
myRoleInGroup: 'owner',
user: {
id: 'owner-of-closed-group',
},
membership: {
role: 'owner',
},
},
},
errors: undefined,
@ -1001,8 +1017,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
JoinGroup: {
id: 'owner-of-hidden-group',
myRoleInGroup: 'owner',
user: {
id: 'owner-of-hidden-group',
},
membership: {
role: 'owner',
},
},
},
errors: undefined,
@ -1208,16 +1228,28 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
id: 'current-user',
myRoleInGroup: 'owner',
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'owner',
}),
}),
expect.objectContaining({
id: 'owner-of-closed-group',
myRoleInGroup: 'usual',
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
}),
expect.objectContaining({
id: 'owner-of-hidden-group',
myRoleInGroup: 'usual',
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
}),
]),
},
@ -1241,16 +1273,28 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
id: 'current-user',
myRoleInGroup: 'owner',
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'owner',
}),
}),
expect.objectContaining({
id: 'owner-of-closed-group',
myRoleInGroup: 'usual',
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
}),
expect.objectContaining({
id: 'owner-of-hidden-group',
myRoleInGroup: 'usual',
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
}),
]),
},
@ -1274,16 +1318,28 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
id: 'current-user',
myRoleInGroup: 'owner',
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'owner',
}),
}),
expect.objectContaining({
id: 'owner-of-closed-group',
myRoleInGroup: 'usual',
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
}),
expect.objectContaining({
id: 'owner-of-hidden-group',
myRoleInGroup: 'usual',
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
}),
]),
},
@ -1317,16 +1373,28 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
id: 'current-user',
myRoleInGroup: 'pending',
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'pending',
}),
}),
expect.objectContaining({
id: 'owner-of-closed-group',
myRoleInGroup: 'owner',
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'owner',
}),
}),
expect.objectContaining({
id: 'owner-of-hidden-group',
myRoleInGroup: 'usual',
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
}),
]),
},
@ -1350,16 +1418,28 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
id: 'current-user',
myRoleInGroup: 'pending',
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'pending',
}),
}),
expect.objectContaining({
id: 'owner-of-closed-group',
myRoleInGroup: 'owner',
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'owner',
}),
}),
expect.objectContaining({
id: 'owner-of-hidden-group',
myRoleInGroup: 'usual',
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
}),
]),
},
@ -1415,20 +1495,36 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
id: 'pending-user',
myRoleInGroup: 'pending',
user: expect.objectContaining({
id: 'pending-user',
}),
membership: expect.objectContaining({
role: 'pending',
}),
}),
expect.objectContaining({
id: 'current-user',
myRoleInGroup: 'usual',
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'usual',
}),
}),
expect.objectContaining({
id: 'owner-of-closed-group',
myRoleInGroup: 'admin',
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'admin',
}),
}),
expect.objectContaining({
id: 'owner-of-hidden-group',
myRoleInGroup: 'owner',
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'owner',
}),
}),
]),
},
@ -1452,20 +1548,36 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
id: 'pending-user',
myRoleInGroup: 'pending',
user: expect.objectContaining({
id: 'pending-user',
}),
membership: expect.objectContaining({
role: 'pending',
}),
}),
expect.objectContaining({
id: 'current-user',
myRoleInGroup: 'usual',
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'usual',
}),
}),
expect.objectContaining({
id: 'owner-of-closed-group',
myRoleInGroup: 'admin',
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'admin',
}),
}),
expect.objectContaining({
id: 'owner-of-hidden-group',
myRoleInGroup: 'owner',
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'owner',
}),
}),
]),
},
@ -1489,20 +1601,36 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
id: 'pending-user',
myRoleInGroup: 'pending',
user: expect.objectContaining({
id: 'pending-user',
}),
membership: expect.objectContaining({
role: 'pending',
}),
}),
expect.objectContaining({
id: 'current-user',
myRoleInGroup: 'usual',
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'usual',
}),
}),
expect.objectContaining({
id: 'owner-of-closed-group',
myRoleInGroup: 'admin',
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'admin',
}),
}),
expect.objectContaining({
id: 'owner-of-hidden-group',
myRoleInGroup: 'owner',
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'owner',
}),
}),
]),
},
@ -1600,8 +1728,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
id: 'usual-member-user',
myRoleInGroup: 'usual',
user: {
id: 'usual-member-user',
},
membership: {
role: 'usual',
},
},
},
errors: undefined,
@ -1638,8 +1770,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
id: 'admin-member-user',
myRoleInGroup: 'admin',
user: {
id: 'admin-member-user',
},
membership: {
role: 'admin',
},
},
},
errors: undefined,
@ -1673,8 +1809,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
id: 'second-owner-member-user',
myRoleInGroup: 'owner',
user: {
id: 'second-owner-member-user',
},
membership: {
role: 'owner',
},
},
},
errors: undefined,
@ -1759,8 +1899,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
id: 'owner-member-user',
myRoleInGroup: 'owner',
user: {
id: 'owner-member-user',
},
membership: {
role: 'owner',
},
},
},
errors: undefined,
@ -1869,8 +2013,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
id: 'admin-member-user',
myRoleInGroup: 'owner',
user: {
id: 'admin-member-user',
},
membership: {
role: 'owner',
},
},
},
errors: undefined,
@ -2047,8 +2195,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
id: 'usual-member-user',
myRoleInGroup: 'admin',
user: {
id: 'usual-member-user',
},
membership: {
role: 'admin',
},
},
},
errors: undefined,
@ -2073,8 +2225,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
id: 'usual-member-user',
myRoleInGroup: 'usual',
user: {
id: 'usual-member-user',
},
membership: {
role: 'usual',
},
},
},
errors: undefined,
@ -2234,8 +2390,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
id: 'pending-member-user',
myRoleInGroup: 'usual',
user: {
id: 'pending-member-user',
},
membership: {
role: 'usual',
},
},
},
errors: undefined,
@ -2260,8 +2420,12 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
id: 'pending-member-user',
myRoleInGroup: 'pending',
user: {
id: 'pending-member-user',
},
membership: {
role: 'pending',
},
},
},
errors: undefined,
@ -2413,7 +2577,7 @@ describe('in mode', () => {
},
})
return result.data?.GroupMembers
? !!result.data.GroupMembers.find((member) => member.id === userId)
? !!result.data.GroupMembers.find((member) => member.user.id === userId)
: null
}
@ -2440,8 +2604,10 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
LeaveGroup: {
id: 'pending-member-user',
myRoleInGroup: null,
user: {
id: 'pending-member-user',
},
membership: null,
},
},
errors: undefined,
@ -2467,8 +2633,10 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
LeaveGroup: {
id: 'usual-member-user',
myRoleInGroup: null,
user: {
id: 'usual-member-user',
},
membership: null,
},
},
errors: undefined,
@ -2494,8 +2662,10 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
LeaveGroup: {
id: 'admin-member-user',
myRoleInGroup: null,
user: {
id: 'admin-member-user',
},
membership: null,
},
},
errors: undefined,
@ -3021,8 +3191,10 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
RemoveUserFromGroup: expect.objectContaining({
id: 'usual-member-user',
myRoleInGroup: null,
user: expect.objectContaining({
id: 'usual-member-user',
}),
membership: null,
}),
},
errors: undefined,
@ -3093,8 +3265,10 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
RemoveUserFromGroup: expect.objectContaining({
id: 'usual-member-user',
myRoleInGroup: null,
user: {
id: 'usual-member-user',
},
membership: null,
}),
},
errors: undefined,

View File

@ -63,7 +63,7 @@ export default {
const readTxResultPromise = session.readTransaction(async (txc) => {
const groupMemberCypher = `
MATCH (user:User)-[membership:MEMBER_OF]->(:Group {id: $groupId})
RETURN user {.*, myRoleInGroup: membership.role}
RETURN user {.*}, membership {.*}
SKIP toInteger($offset) LIMIT toInteger($first)
`
const transactionResponse = await txc.run(groupMemberCypher, {
@ -71,7 +71,9 @@ export default {
first,
offset,
})
return transactionResponse.records.map((record) => record.get('user'))
return transactionResponse.records.map((record) => {
return { user: record.get('user'), membership: record.get('membership') }
})
})
try {
return await readTxResultPromise
@ -273,8 +275,8 @@ export default {
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const joinGroupCypher = `
MATCH (member:User {id: $userId}), (group:Group {id: $groupId})
MERGE (member)-[membership:MEMBER_OF]->(group)
MATCH (user:User {id: $userId}), (group:Group {id: $groupId})
MERGE (user)-[membership:MEMBER_OF]->(group)
ON CREATE SET
membership.createdAt = toString(datetime()),
membership.updatedAt = null,
@ -283,14 +285,15 @@ export default {
THEN 'usual'
ELSE 'pending'
END
RETURN member {.*, myRoleInGroup: membership.role}
RETURN user {.*}, membership {.*}
`
const transactionResponse = await transaction.run(joinGroupCypher, { groupId, userId })
const [member] = transactionResponse.records.map((record) => record.get('member'))
return member
return transactionResponse.records.map((record) => {
return { user: record.get('user'), membership: record.get('membership') }
})
})
try {
return await writeTxResultPromise
return (await writeTxResultPromise)[0]
} catch (error) {
throw new Error(error)
} finally {
@ -337,7 +340,7 @@ export default {
membership.updatedAt = toString(datetime()),
membership.role = $roleInGroup
${postRestrictionCypher}
RETURN member {.*, myRoleInGroup: membership.role}
RETURN member {.*} as user, membership {.*}
`
const transactionResponse = await transaction.run(joinGroupCypher, {
@ -345,7 +348,9 @@ export default {
userId,
roleInGroup,
})
const [member] = transactionResponse.records.map((record) => record.get('member'))
const [member] = transactionResponse.records.map((record) => {
return { user: record.get('user'), membership: record.get('membership') }
})
return member
})
try {
@ -471,6 +476,18 @@ export default {
})
).records.map((r) => r.get('inviteCodes'))
},
currentlyPinnedPostsCount: async (parent, _args, context: Context, _resolveInfo) => {
if (!parent.id) {
throw new Error('Can not identify selected Group!')
}
const result = await context.database.query({
query: `
MATCH (:User)-[pinned:GROUP_PINNED]->(pinnedPosts:Post)-[:IN]->(:Group {id: $group.id})
RETURN toString(count(pinnedPosts)) as count`,
variables: { group: parent },
})
return result.records[0].get('count')
},
...Resolver('Group', {
undefinedToNull: ['deleted', 'disabled', 'locationName', 'about'],
hasMany: {
@ -516,14 +533,16 @@ const removeUserFromGroupWriteTxResultPromise = async (session, groupId, userId)
WITH user, collect(p) AS posts
FOREACH (post IN posts |
MERGE (user)-[:CANNOT_SEE]->(post))
RETURN user {.*, myRoleInGroup: NULL}
RETURN user {.*}, NULL as membership
`
const transactionResponse = await transaction.run(removeUserFromGroupCypher, {
groupId,
userId,
})
const [user] = await transactionResponse.records.map((record) => record.get('user'))
const [user] = await transactionResponse.records.map((record) => {
return { user: record.get('user'), membership: record.get('membership') }
})
return user
})
}

View File

@ -84,9 +84,12 @@ export const images = (config: S3Config) => {
const uploadImageFile = async (uploadPromise: Promise<FileUpload> | undefined) => {
if (!uploadPromise) return undefined
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const upload = await uploadPromise
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
const { name, ext } = path.parse(upload.filename)
const uniqueFilename = `${uuid()}-${slug(name)}${ext}`
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return await s3.uploadFile({ ...upload, uniqueFilename })
}

View File

@ -1089,16 +1089,24 @@ describe('redeemInviteCode', () => {
data: {
GroupMembers: expect.arrayContaining([
{
id: 'inviting-user',
myRoleInGroup: 'owner',
name: 'Inviting User',
slug: 'inviting-user',
user: {
id: 'inviting-user',
name: 'Inviting User',
slug: 'inviting-user',
},
membership: {
role: 'owner',
},
},
{
id: 'other-user',
myRoleInGroup: 'pending',
name: 'Other User',
slug: 'other-user',
user: {
id: 'other-user',
name: 'Other User',
slug: 'other-user',
},
membership: {
role: 'pending',
},
},
]),
},

View File

@ -1,41 +1,34 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '@db/factories'
import { getNeode, getDriver } from '@db/neo4j'
import { UpdateUser } from '@graphql/queries/UpdateUser'
import { User } from '@graphql/queries/User'
import createServer from '@src/server'
import { createApolloTestSetup } from '@root/test/helpers'
import type { ApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
let query, mutate, authenticatedUser
const driver = getDriver()
const neode = getNeode()
let authenticatedUser: Context['user']
const context = () => ({ authenticatedUser })
let mutate: ApolloTestSetup['mutate']
let query: ApolloTestSetup['query']
let database: ApolloTestSetup['database']
let server: ApolloTestSetup['server']
beforeAll(async () => {
await cleanDatabase()
const { server } = createServer({
context: () => {
return {
driver,
neode,
user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
}
},
})
query = createTestClient(server).query
mutate = createTestClient(server).mutate
const apolloSetup = createApolloTestSetup({ context })
mutate = apolloSetup.mutate
query = apolloSetup.query
database = apolloSetup.database
server = apolloSetup.server
})
afterAll(async () => {
await cleanDatabase()
await driver.close()
void server.stop()
void database.driver.close()
database.neode.close()
})
// TODO: avoid database clean after each test in the future if possible for performance and flakyness reasons by filling the database step by step, see issue https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4543

View File

@ -0,0 +1,368 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import Factory, { cleanDatabase } from '@db/factories'
import { ChangeGroupMemberRole } from '@graphql/queries/ChangeGroupMemberRole'
import { CreateGroup } from '@graphql/queries/CreateGroup'
import { CreatePost } from '@graphql/queries/CreatePost'
import { pinGroupPost } from '@graphql/queries/pinGroupPost'
import { profilePagePosts } from '@graphql/queries/profilePagePosts'
import { unpinGroupPost } from '@graphql/queries/unpinGroupPost'
import type { ApolloTestSetup } from '@root/test/helpers'
import { createApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
const defaultConfig = {
CATEGORIES_ACTIVE: false,
}
let config: Partial<Context['config']>
let anyUser
let allGroupsUser
let publicUser
let publicAdminUser
let authenticatedUser: Context['user']
const context = () => ({ authenticatedUser, config })
let mutate: ApolloTestSetup['mutate']
let query: ApolloTestSetup['query']
let database: ApolloTestSetup['database']
let server: ApolloTestSetup['server']
beforeAll(async () => {
await cleanDatabase()
const apolloSetup = createApolloTestSetup({ context })
mutate = apolloSetup.mutate
query = apolloSetup.query
database = apolloSetup.database
server = apolloSetup.server
})
afterAll(() => {
void server.stop()
void database.driver.close()
database.neode.close()
})
beforeEach(async () => {
config = { ...defaultConfig }
authenticatedUser = null
anyUser = await Factory.build('user', {
id: 'any-user',
name: 'Any User',
about: 'I am just an ordinary user and do not belong to any group.',
})
allGroupsUser = await Factory.build('user', {
id: 'all-groups-user',
name: 'All Groups User',
about: 'I am a member of all groups.',
})
publicUser = await Factory.build('user', {
id: 'public-user',
name: 'Public User',
about: 'I am the owner of the public group.',
})
publicAdminUser = await Factory.build('user', {
id: 'public-admin-user',
name: 'Public Admin User',
about: 'I am the admin of the public group.',
})
authenticatedUser = await publicUser.toJson()
await mutate({
mutation: CreateGroup,
variables: {
id: 'public-group',
name: 'The Public Group',
about: 'The public group!',
description: 'Anyone can see the posts of this group.',
groupType: 'public',
actionRadius: 'regional',
},
})
await mutate({
mutation: ChangeGroupMemberRole,
variables: {
groupId: 'public-group',
userId: 'all-groups-user',
roleInGroup: 'usual',
},
})
await mutate({
mutation: ChangeGroupMemberRole,
variables: {
groupId: 'public-group',
userId: 'public-admin-user',
roleInGroup: 'admin',
},
})
await mutate({
mutation: ChangeGroupMemberRole,
variables: {
groupId: 'closed-group',
userId: 'all-groups-user',
roleInGroup: 'usual',
},
})
authenticatedUser = await anyUser.toJson()
await mutate({
mutation: CreatePost,
variables: {
id: 'post-without-group',
title: 'A post without a group',
content: 'I am a user who does not belong to a group yet.',
},
})
authenticatedUser = await publicUser.toJson()
await mutate({
mutation: CreatePost,
variables: {
id: 'post-1-to-public-group',
title: 'Post 1 to a public group',
content: 'I am posting into a public group as a member of the group',
groupId: 'public-group',
},
})
await mutate({
mutation: CreatePost,
variables: {
id: 'post-2-to-public-group',
title: 'Post 1 to a public group',
content: 'I am posting into a public group as a member of the group',
groupId: 'public-group',
},
})
await mutate({
mutation: CreatePost,
variables: {
id: 'post-3-to-public-group',
title: 'Post 1 to a public group',
content: 'I am posting into a public group as a member of the group',
groupId: 'public-group',
},
})
})
afterEach(async () => {
await cleanDatabase()
})
describe('pin groupPosts', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
data: { pinGroupPost: null },
})
})
})
describe('ordinary users', () => {
it('throws authorization error', async () => {
authenticatedUser = await anyUser.toJson()
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
data: { pinGroupPost: null },
})
})
})
describe('group usual', () => {
it('throws authorization error', async () => {
authenticatedUser = await allGroupsUser.toJson()
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
data: { pinGroupPost: null },
})
})
})
describe('group admin', () => {
it('resolves without error', async () => {
authenticatedUser = await publicAdminUser.toJson()
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }),
).resolves.toMatchObject({
errors: undefined,
data: { pinGroupPost: { id: 'post-1-to-public-group', groupPinned: true } },
})
})
})
describe('group owner', () => {
it('resolves without error', async () => {
authenticatedUser = await publicUser.toJson()
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }),
).resolves.toMatchObject({
errors: undefined,
data: { pinGroupPost: { id: 'post-1-to-public-group', groupPinned: true } },
})
})
})
describe('MAX_GROUP_PINNED_POSTS is 1', () => {
beforeEach(async () => {
config = { ...defaultConfig, MAX_GROUP_PINNED_POSTS: 1 }
authenticatedUser = await publicUser.toJson()
})
it('returns post-1-to-public-group as first, pinned post', async () => {
await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } })
await expect(
query({
query: profilePagePosts,
variables: {
filter: { group: { id: 'public-group' } },
orderBy: ['groupPinned_asc', 'sortDate_desc'],
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
profilePagePosts: [
expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: null }),
expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: null }),
],
},
})
})
it('no error thrown when pinned post was pinned again', async () => {
await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } })
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }),
).resolves.toMatchObject({
errors: undefined,
data: { pinGroupPost: { id: 'post-1-to-public-group', groupPinned: true } },
})
})
it('returns post-2-to-public-group as first, pinned post', async () => {
authenticatedUser = await publicUser.toJson()
await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } })
await expect(
query({
query: profilePagePosts,
variables: {
filter: { group: { id: 'public-group' } },
orderBy: ['groupPinned_asc', 'sortDate_desc'],
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
profilePagePosts: [
expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: null }),
expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: null }),
],
},
})
})
it('returns post-3-to-public-group as first, pinned post, when multiple are pinned', async () => {
authenticatedUser = await publicUser.toJson()
await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } })
await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } })
await mutate({ mutation: pinGroupPost, variables: { id: 'post-3-to-public-group' } })
await expect(
query({
query: profilePagePosts,
variables: {
filter: { group: { id: 'public-group' } },
orderBy: ['groupPinned_asc', 'sortDate_desc'],
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
profilePagePosts: [
expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: null }),
expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: null }),
],
},
})
})
})
describe('MAX_GROUP_PINNED_POSTS is 2', () => {
beforeEach(async () => {
config = { ...defaultConfig, MAX_GROUP_PINNED_POSTS: 2 }
authenticatedUser = await publicUser.toJson()
})
it('returns post-1-to-public-group as first, post-2-to-public-group as second pinned post', async () => {
await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } })
await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } })
await expect(
query({
query: profilePagePosts,
variables: {
filter: { group: { id: 'public-group' } },
orderBy: ['groupPinned_asc', 'sortDate_desc'],
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
profilePagePosts: [
expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: null }),
],
},
})
})
it('throws an error when three posts are pinned', async () => {
await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } })
await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } })
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-3-to-public-group' } }),
).resolves.toMatchObject({
errors: [{ message: 'Reached maxed pinned posts already. Unpin a post first.' }],
data: {
pinGroupPost: null,
},
})
})
it('throws no error when first unpinned before a third post is pinned', async () => {
await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } })
await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } })
await mutate({ mutation: unpinGroupPost, variables: { id: 'post-1-to-public-group' } })
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-3-to-public-group' } }),
).resolves.toMatchObject({
errors: undefined,
})
await expect(
query({
query: profilePagePosts,
variables: {
filter: { group: { id: 'public-group' } },
orderBy: ['groupPinned_asc', 'sortDate_desc'],
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
profilePagePosts: [
expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: null }),
],
},
})
})
})
})

View File

@ -31,6 +31,20 @@ const maintainPinnedPosts = (params) => {
return params
}
const maintainGroupPinnedPosts = (params) => {
// only show GroupPinnedPosts when Groups is selected
if (!params.filter?.group) {
return params
}
const pinnedPostFilter = { groupPinned: true, group: params.filter.group }
if (isEmpty(params.filter)) {
params.filter = { OR: [pinnedPostFilter, {}] }
} else {
params.filter = { OR: [pinnedPostFilter, { ...params.filter }] }
}
return params
}
const filterEventDates = (params) => {
if (params.filter?.eventStart_gte) {
const date = params.filter.eventStart_gte
@ -54,6 +68,7 @@ export default {
params = await filterPostsOfMyGroups(params, context)
params = await filterInvisiblePosts(params, context)
params = await filterForMutedUsers(params, context)
params = await maintainGroupPinnedPosts(params)
return neo4jgraphql(object, params, context, resolveInfo)
},
PostsEmotionsCountByEmotion: async (_object, params, context, _resolveInfo) => {
@ -555,6 +570,68 @@ export default {
}
return unpinnedPost
},
pinGroupPost: async (_parent, params, context: Context, _resolveInfo) => {
if (!context.user) {
throw new Error('Missing authenticated user.')
}
const { config } = context
if (config.MAX_GROUP_PINNED_POSTS === 0) {
throw new Error('Pinned posts are not allowed!')
}
// If MAX_GROUP_PINNED_POSTS === 1 -> Delete old pin
if (config.MAX_GROUP_PINNED_POSTS === 1) {
await context.database.write({
query: `
MATCH (post:Post {id: $params.id})-[:IN]->(group:Group)
MATCH (:User)-[pinned:GROUP_PINNED]->(oldPinnedPost:Post)-[:IN]->(:Group {id: group.id})
REMOVE oldPinnedPost.groupPinned
DELETE pinned`,
variables: { user: context.user, params },
})
// If MAX_GROUP_PINNED_POSTS !== 1 -> Check if max is reached
} else {
const result = await context.database.query({
query: `
MATCH (post:Post {id: $params.id})-[:IN]->(group:Group)
MATCH (:User)-[pinned:GROUP_PINNED]->(pinnedPosts:Post)-[:IN]->(:Group {id: group.id})
RETURN toString(count(pinnedPosts)) as count`,
variables: { user: context.user, params },
})
if (result.records[0].get('count') >= config.MAX_GROUP_PINNED_POSTS) {
throw new Error('Reached maxed pinned posts already. Unpin a post first.')
}
}
// Set new pin
const result = await context.database.write({
query: `
MATCH (user:User {id: $user.id})
MATCH (post:Post {id: $params.id})-[:IN]->(group:Group)
MERGE (user)-[pinned:GROUP_PINNED {createdAt: toString(datetime())}]->(post)
SET post.groupPinned = true
RETURN post {.*, pinnedAt: pinned.createdAt}`,
variables: { user: context.user, params },
})
// Return post
return result.records[0].get('post')
},
unpinGroupPost: async (_parent, params, context, _resolveInfo) => {
const result = await context.database.write({
query: `
MATCH (post:Post {id: $postId})
OPTIONAL MATCH (:User)-[pinned:GROUP_PINNED]->(post)
DELETE pinned
REMOVE post.groupPinned
RETURN post {.*}`,
variables: { postId: params.id },
})
// Return post
return result.records[0].get('post')
},
markTeaserAsViewed: async (_parent, params, context, _resolveInfo) => {
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
@ -652,6 +729,7 @@ export default {
'language',
'pinnedAt',
'pinned',
'groupPinned',
'eventVenue',
'eventLocation',
'eventLocationName',
@ -691,6 +769,21 @@ export default {
'MATCH (this)<-[obs:OBSERVES]-(related:User {id: $cypherParams.currentUserId}) WHERE obs.active = true RETURN COUNT(related) >= 1',
},
}),
// As long as we rely on the filter capabilities of the neo4jgraphql library,
// we cannot filter on a relation or their properties.
// Hence we need to save the value to the group node in the database.
/* groupPinned: async (parent, _params, context, _resolveInfo) => {
return (
(
await context.database.query({
query: `
MATCH (:User)-[pinned:GROUP_PINNED]->(:Post {id: $parent.id})
RETURN pinned`,
variables: { parent },
})
).records.length === 1
)
}, */
relatedContributions: async (parent, _params, context, _resolveInfo) => {
if (typeof parent.relatedContributions !== 'undefined') return parent.relatedContributions
const { id } = parent

View File

@ -1,12 +1,9 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { AuthenticationError } from 'apollo-server'
import bcrypt from 'bcryptjs'
import { neo4jgraphql } from 'neo4j-graphql-js'
import { getNeode } from '@db/neo4j'
import { encode } from '@jwt/encode'
@ -18,8 +15,21 @@ const neode = getNeode()
export default {
Query: {
currentUser: async (object, params, context, resolveInfo) =>
neo4jgraphql(object, { id: context.user.id }, context, resolveInfo),
currentUser: async (_object, _params, context: Context, _resolveInfo) => {
if (!context.user) {
throw new Error('You must be logged in')
}
const [user] = (
await context.database.query({
query: `
MATCH (user:User {id: $user.id})-[:PRIMARY_EMAIL]->(e:EmailAddress)
RETURN user {.*, email: e.email}
`,
variables: { user: context.user },
})
).records.map((record) => record.get('user'))
return user
},
},
Mutation: {
login: async (_, { email, password }, context: Context) => {

View File

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-call */
@ -458,6 +457,18 @@ export default {
},
},
User: {
activeCategories: async (parent, _args, context: Context, _resolveInfo) => {
return (
await context.database.query({
query: `
MATCH (category:Category)
WHERE NOT ((:User{id: $user.id})-[:NOT_INTERESTED_IN]->(category))
RETURN collect(category.id) as categories
`,
variables: { user: parent },
})
).records.map((record) => record.get('categories'))[0]
},
inviteCodes: async (_parent, _args, context: Context, _resolveInfo) => {
return (
await context.database.query({
@ -471,7 +482,7 @@ export default {
})
).records.map((record) => record.get('inviteCodes'))
},
emailNotificationSettings: async (parent, _params, _context, _resolveInfo) => {
emailNotificationSettings: (parent, _params, _context, _resolveInfo) => {
return [
{
type: 'post',
@ -633,7 +644,6 @@ export default {
'allowEmbedIframes',
'showShoutsPublicly',
'locale',
'activeCategories',
],
boolean: {
followedByCurrentUser:

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { makeAugmentedSchema } from 'neo4j-graphql-js'
import typeDefs from '@graphql/types/index'

View File

@ -0,0 +1,14 @@
# directive @MutationMeta on FIELD_DEFINITION
# directive @isAuthenticated on FIELD_DEFINITION
# directive @hasRole on FIELD_DEFINITION
# directive @hasScope on FIELD_DEFINITION
# directive @additionalLabels on FIELD_DEFINITION
directive @cypher(statement: String) on FIELD_DEFINITION
directive @relation(
name: String
from: String
to: String
direction: String
) on FIELD_DEFINITION | OBJECT
directive @neo4j_ignore on FIELD_DEFINITION

View File

@ -2,4 +2,4 @@ enum EmailNotificationSettingsType {
post
chat
group
}
}

View File

@ -4,4 +4,4 @@ enum Emotion {
happy
angry
funny
}
}

View File

@ -1,4 +1,4 @@
enum ShoutTypeEnum {
Post
Comment
}
}

View File

@ -2,4 +2,4 @@ enum Visibility {
public
friends
private
}
}

View File

@ -50,12 +50,16 @@ type Comment {
isPostObservedByMe: Boolean!
@cypher(
statement: "MATCH (this)-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(u:User {id: $cypherParams.currentUserId}) WHERE obs.active = true RETURN COUNT(u) >= 1"
)
postObservingUsersCount: Int!
@cypher(statement: "MATCH (this)-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(u:User) WHERE obs.active = true AND NOT u.disabled = true AND NOT u.deleted = true RETURN COUNT(DISTINCT u)")
)
postObservingUsersCount: Int!
@cypher(
statement: "MATCH (this)-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(u:User) WHERE obs.active = true AND NOT u.disabled = true AND NOT u.deleted = true RETURN COUNT(DISTINCT u)"
)
shoutedByCurrentUser: Boolean!
@cypher(statement: "MATCH (this) RETURN EXISTS((this)<-[:SHOUTED]-(:User {id: $cypherParams.currentUserId}))")
@cypher(
statement: "MATCH (this) RETURN EXISTS((this)<-[:SHOUTED]-(:User {id: $cypherParams.currentUserId}))"
)
shoutedCount: Int!
@cypher(
@ -77,16 +81,7 @@ type Query {
}
type Mutation {
CreateComment(
id: ID
postId: ID!
content: String!
contentExcerpt: String
): Comment
UpdateComment(
id: ID!
content: String!
contentExcerpt: String
): Comment
CreateComment(id: ID, postId: ID!, content: String!, contentExcerpt: String): Comment
UpdateComment(id: ID!, content: String!, contentExcerpt: String): Comment
DeleteComment(id: ID!): Comment
}

View File

@ -13,4 +13,4 @@ type Query {
type Mutation {
UpdateDonations(showDonations: Boolean, goal: Int, progress: Int): Donations
}
}

View File

@ -9,11 +9,7 @@ type Query {
}
type Mutation {
Signup(
email: String!
locale: String!
inviteCode: String = null
): EmailAddress
Signup(email: String!, locale: String!, inviteCode: String = null): EmailAddress
SignupVerification(
nonce: String!
email: String!
@ -27,8 +23,5 @@ type Mutation {
locationName: String = null
): User
AddEmailAddress(email: String!): EmailAddress
VerifyEmailAddress(
nonce: String!
email: String!
): EmailAddress
VerifyEmailAddress(nonce: String!, email: String!): EmailAddress
}

View File

@ -5,7 +5,7 @@ type FILED {
submitter: User
}
# this list equals the strings of an array in file "webapp/constants/modals.js"
"this list equals the strings of an array in file `webapp/constants/modals.js`"
enum ReasonCategory {
other
discrimination_etc
@ -26,5 +26,9 @@ type FiledReport {
}
type Mutation {
fileReport(resourceId: ID!, reasonCategory: ReasonCategory!, reasonDescription: String!): FiledReport
}
fileReport(
resourceId: ID!
reasonCategory: ReasonCategory!
reasonDescription: String!
): FiledReport
}

View File

@ -1,16 +1,16 @@
type File {
url: ID!,
name: String,
#size: Int,
type: String,
#audio: Boolean,
#duration: Float,
#preview: String,
#progress: Int,
url: ID!
name: String
type: String
# size: Int
# audio: Boolean
# duration: Float
# preview: String
# progress: Int
}
input FileInput {
upload: Upload,
name: String,
type: String,
upload: Upload
name: String
type: String
}

View File

@ -1,19 +1,19 @@
enum _GroupOrdering {
id_asc
id_desc
name_asc
name_desc
slug_asc
slug_desc
locationName_asc
locationName_desc
about_asc
about_desc
createdAt_asc
createdAt_desc
updatedAt_asc
updatedAt_desc
}
# enum _GroupOrdering {
# id_asc
# id_desc
# name_asc
# name_desc
# slug_asc
# slug_desc
# locationName_asc
# locationName_desc
# about_asc
# about_desc
# createdAt_asc
# createdAt_desc
# updatedAt_asc
# updatedAt_desc
# }
type Group {
id: ID!
@ -38,18 +38,27 @@ type Group {
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
membersCount: Int! @cypher(statement: "MATCH (this)<-[:MEMBER_OF]-(r:User) RETURN COUNT(DISTINCT r)")
membersCount: Int!
@cypher(statement: "MATCH (this)<-[:MEMBER_OF]-(r:User) RETURN COUNT(DISTINCT r)")
myRole: GroupMemberRole # if 'null' then the current user is no member
posts: [Post] @relation(name: "IN", direction: "IN")
isMutedByMe: Boolean! @cypher(statement: "MATCH (this) RETURN EXISTS( (this)<-[:MUTED]-(:User {id: $cypherParams.currentUserId}) )")
isMutedByMe: Boolean!
@cypher(
statement: "MATCH (this) RETURN EXISTS( (this)<-[:MUTED]-(:User {id: $cypherParams.currentUserId}) )"
)
"inviteCodes to this group the current user has generated"
inviteCodes: [InviteCode]! @neo4j_ignore
currentlyPinnedPostsCount: Int! @neo4j_ignore
}
type GroupMember {
user: User
membership: MEMBER_OF
}
input _GroupFilter {
AND: [_GroupFilter!]
@ -74,20 +83,16 @@ type Query {
slug: String
first: Int
offset: Int
# orderBy: [_GroupOrdering] # not implemented yet
# filter: _GroupFilter # not implemented yet
): [Group]
# orderBy: [_GroupOrdering] # not implemented yet
# filter: _GroupFilter # not implemented yet
GroupMembers(
id: ID!
first: Int
offset: Int
# orderBy: [_UserOrdering] # not implemented yet
# filter: _UserFilter # not implemented yet
): [User]
GroupMembers(id: ID!, first: Int, offset: Int): [GroupMember]
# orderBy: [_UserOrdering] # not implemented yet
# filter: _UserFilter # not implemented yet
GroupCount(isMember: Boolean): Int
# AvailableGroupTypes: [GroupType]!
# AvailableGroupActionRadii: [GroupActionRadius]!
@ -105,7 +110,9 @@ type Mutation {
groupType: GroupType!
actionRadius: GroupActionRadius!
categoryIds: [ID]
# avatar: ImageInput # a group can not be created with an avatar
locationName: String # empty string '' sets it to null
): Group
@ -115,7 +122,9 @@ type Mutation {
slug: String
about: String
description: String
# groupType: GroupType # is not possible at the moment and has to be discussed. may be in the stronger direction: public → closed → hidden
actionRadius: GroupActionRadius
categoryIds: [ID]
avatar: ImageInput # test this as result
@ -124,27 +133,14 @@ type Mutation {
# DeleteGroup(id: ID!): Group
JoinGroup(
groupId: ID!
userId: ID!
): User
JoinGroup(groupId: ID!, userId: ID!): GroupMember
LeaveGroup(
groupId: ID!
userId: ID!
): User
LeaveGroup(groupId: ID!, userId: ID!): GroupMember
ChangeGroupMemberRole(
groupId: ID!
userId: ID!
roleInGroup: GroupMemberRole!
): User
ChangeGroupMemberRole(groupId: ID!, userId: ID!, roleInGroup: GroupMemberRole!): GroupMember
RemoveUserFromGroup(
groupId: ID!
userId: ID!
): User
RemoveUserFromGroup(groupId: ID!, userId: ID!): GroupMember
muteGroup(groupId: ID!): Group
unmuteGroup(groupId: ID!): Group
unmuteGroup(groupId: ID!): Group
}

View File

@ -1,21 +1,23 @@
type Image {
url: ID!,
url: ID!
transform(width: Int, height: Int): String
# urlW34: String,
# urlW160: String,
# urlW320: String,
# urlW640: String,
# urlW1024: String,
alt: String,
sensitive: Boolean,
aspectRatio: Float,
type: String,
alt: String
sensitive: Boolean
aspectRatio: Float
type: String
}
input ImageInput {
alt: String,
upload: Upload,
sensitive: Boolean,
aspectRatio: Float,
type: String,
alt: String
upload: Upload
sensitive: Boolean
aspectRatio: Float
type: String
}

View File

@ -19,7 +19,11 @@ type Query {
type Mutation {
generatePersonalInviteCode(expiresAt: String = null, comment: String = null): InviteCode!
generateGroupInviteCode(groupId: ID!, expiresAt: String = null, comment: String = null): InviteCode!
generateGroupInviteCode(
groupId: ID!
expiresAt: String = null
comment: String = null
): InviteCode!
invalidateInviteCode(code: String!): InviteCode
redeemInviteCode(code: String!): Boolean!
}

View File

@ -18,6 +18,7 @@ type Location {
}
# This is not smart - we need one location for everything - use the same type everywhere!
type LocationMapBox {
id: ID!
place_name: String!

View File

@ -19,8 +19,11 @@ type Message {
senderId: String! @cypher(statement: "MATCH (this)<-[:CREATED]-(user:User) RETURN user.id")
username: String! @cypher(statement: "MATCH (this)<-[:CREATED]-(user:User) RETURN user.name")
avatar: String @cypher(statement: "MATCH (this)<-[:CREATED]-(:User)-[:AVATAR_IMAGE]->(image:Image) RETURN image.url")
date: String! @cypher(statement: "RETURN this.createdAt")
avatar: String
@cypher(
statement: "MATCH (this)<-[:CREATED]-(:User)-[:AVATAR_IMAGE]->(image:Image) RETURN image.url"
)
date: String! @cypher(statement: "RETURN this.createdAt")
saved: Boolean
distributed: Boolean
@ -29,22 +32,13 @@ type Message {
}
type Mutation {
CreateMessage(
roomId: ID!
content: String
files: [FileInput]
): Message
CreateMessage(roomId: ID!, content: String, files: [FileInput]): Message
MarkMessagesAsSeen(messageIds: [String!]): Boolean
}
type Query {
Message(
roomId: ID!,
first: Int
offset: Int
orderBy: [_MessageOrdering]
): [Message]
Message(roomId: ID!, first: Int, offset: Int, orderBy: [_MessageOrdering]): [Message]
}
type Subscription {

View File

@ -33,7 +33,7 @@ enum NotificationReason {
type Query {
notifications(read: Boolean, orderBy: NotificationOrdering, first: Int, offset: Int): [NOTIFIED]
}
type Mutation {
markAsRead(id: ID!): NOTIFIED
markAllAsRead: [NOTIFIED]

View File

@ -1,3 +1,7 @@
input _CategoryFilter {
AND: [_CategoryFilter!]
OR: [_CategoryFilter!]
}
input _PostFilter {
AND: [_PostFilter!]
OR: [_PostFilter!]
@ -49,6 +53,7 @@ input _PostFilter {
language_in: [String!]
language_not_in: [String!]
pinned: Boolean # required for `maintainPinnedPost`
groupPinned: Boolean # required for `maintainGroupPinnedPost`
tags: _TagFilter
tags_not: _TagFilter
tags_in: [_TagFilter!]
@ -111,9 +116,10 @@ enum _PostOrdering {
pinned_desc
eventStart_asc
eventStart_desc
groupPinned_asc
groupPinned_desc
}
type Post {
id: ID!
activityId: String
@ -128,14 +134,16 @@ type Post {
deleted: Boolean
disabled: Boolean
pinned: Boolean
groupPinned: Boolean
createdAt: String
updatedAt: String
sortDate: String
language: String
pinnedAt: String @cypher(
pinnedAt: String
@cypher(
statement: "MATCH (this)<-[pinned:PINNED]-(:User) WHERE NOT this.deleted = true AND NOT this.disabled = true RETURN pinned.createdAt"
)
pinnedBy: User @relation(name:"PINNED", direction: "IN")
pinnedBy: User @relation(name: "PINNED", direction: "IN")
relatedContributions: [Post]!
@cypher(
statement: """
@ -160,7 +168,7 @@ type Post {
statement: "MATCH (this)<-[:SHOUTED]-(r:User) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)"
)
# Has the currently logged in user shouted that post?
"Has the currently logged in user shouted that post?"
shoutedByCurrentUser: Boolean!
@cypher(
statement: "MATCH (this)<-[:SHOUTED]-(u:User {id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1"
@ -180,8 +188,7 @@ type Post {
group: Group @relation(name: "IN", direction: "OUT")
postType: [PostType]
@cypher(statement: "RETURN [l IN labels(this) WHERE NOT l = 'Post']")
postType: [PostType] @cypher(statement: "RETURN [l IN labels(this) WHERE NOT l = 'Post']")
eventLocationName: String
eventLocation: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l")
@ -193,9 +200,11 @@ type Post {
isObservedByMe: Boolean!
@cypher(
statement: "MATCH (this)<-[obs:OBSERVES]-(u:User {id: $cypherParams.currentUserId}) WHERE obs.active = true RETURN COUNT(u) >= 1"
)
)
observingUsersCount: Int!
@cypher(statement: "MATCH (this)<-[obs:OBSERVES]-(u:User) WHERE obs.active = true AND NOT u.deleted = true AND NOT u.disabled = true RETURN COUNT(DISTINCT u)")
@cypher(
statement: "MATCH (this)<-[obs:OBSERVES]-(u:User) WHERE obs.active = true AND NOT u.deleted = true AND NOT u.disabled = true RETURN COUNT(DISTINCT u)"
)
}
input _PostInput {
@ -243,15 +252,19 @@ type Mutation {
DeletePost(id: ID!): Post
AddPostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED
RemovePostEmotions(to: _PostInput!, data: _EMOTEDInput!): EMOTED
pinPost(id: ID!): Post
unpinPost(id: ID!): Post
pinGroupPost(id: ID!): Post
unpinGroupPost(id: ID!): Post
markTeaserAsViewed(id: ID!): Post
pushPost(id: ID!): Post!
unpushPost(id: ID!): Post!
# Shout the given Type and ID
"Shout the given Type and ID"
shout(id: ID!, type: ShoutTypeEnum!): Boolean!
# Unshout the given Type and ID
"Unshout the given Type and ID"
unshout(id: ID!, type: ShoutTypeEnum!): Boolean!
toggleObservePost(id: ID!, value: Boolean!): Post!

View File

@ -17,7 +17,13 @@ enum ReportRule {
}
type Query {
reports(orderBy: ReportOrdering, first: Int, offset: Int, reviewed: Boolean, closed: Boolean): [Report]
reports(
orderBy: ReportOrdering
first: Int
offset: Int
reviewed: Boolean
closed: Boolean
): [Report]
}
enum ReportOrdering {

View File

@ -6,6 +6,7 @@
# }
# TODO change this to last message date
enum _RoomOrdering {
lastMessageAt_desc
createdAt_desc
@ -19,41 +20,48 @@ type Room {
users: [User]! @relation(name: "CHATS_IN", direction: "IN")
roomId: String! @cypher(statement: "RETURN this.id")
roomName: String! @cypher(statement: "MATCH (this)<-[:CHATS_IN]-(user:User) WHERE NOT user.id = $cypherParams.currentUserId RETURN user.name")
avatar: String @cypher(statement: """
MATCH (this)<-[:CHATS_IN]-(user:User)
WHERE NOT user.id = $cypherParams.currentUserId
OPTIONAL MATCH (user)-[:AVATAR_IMAGE]->(image:Image)
RETURN image.url
""")
roomName: String!
@cypher(
statement: "MATCH (this)<-[:CHATS_IN]-(user:User) WHERE NOT user.id = $cypherParams.currentUserId RETURN user.name"
)
avatar: String
@cypher(
statement: """
MATCH (this)<-[:CHATS_IN]-(user:User)
WHERE NOT user.id = $cypherParams.currentUserId
OPTIONAL MATCH (user)-[:AVATAR_IMAGE]->(image:Image)
RETURN image.url
"""
)
lastMessageAt: String
lastMessage: Message @cypher(statement: """
MATCH (this)<-[:INSIDE]-(message:Message)
WITH message ORDER BY message.indexId DESC LIMIT 1
RETURN message
""")
lastMessage: Message
@cypher(
statement: """
MATCH (this)<-[:INSIDE]-(message:Message)
WITH message ORDER BY message.indexId DESC LIMIT 1
RETURN message
"""
)
unreadCount: Int @cypher(statement: """
MATCH (this)<-[:INSIDE]-(message:Message)<-[:CREATED]-(user:User)
WHERE NOT user.id = $cypherParams.currentUserId
AND NOT message.seen
RETURN count(message)
""")
unreadCount: Int
@cypher(
statement: """
MATCH (this)<-[:INSIDE]-(message:Message)<-[:CREATED]-(user:User)
WHERE NOT user.id = $cypherParams.currentUserId
AND NOT message.seen
RETURN count(message)
"""
)
}
type Mutation {
CreateRoom(
userId: ID!
): Room
CreateRoom(userId: ID!): Room
}
type Query {
Room(
id: ID
orderBy: [_RoomOrdering]
): [Room]
Room(id: ID, orderBy: [_RoomOrdering]): [Room]
UnreadRooms: Int
}

View File

@ -25,4 +25,3 @@ type Statistics {
usersVerified: Int!
reports: Int!
}

View File

@ -19,7 +19,8 @@ type Tag {
id: ID!
taggedPosts: [Post]! @relation(name: "TAGGED", direction: "IN")
taggedCount: Int! @cypher(statement: "MATCH (this)<-[:TAGGED]-(p) RETURN COUNT(DISTINCT p)")
taggedCountUnique: Int! @cypher(statement: "MATCH (this)<-[:TAGGED]-(p)<-[:WROTE]-(u:User) RETURN COUNT(DISTINCT u)")
taggedCountUnique: Int!
@cypher(statement: "MATCH (this)<-[:TAGGED]-(p)<-[:WROTE]-(u:User) RETURN COUNT(DISTINCT u)")
deleted: Boolean
disabled: Boolean
}
@ -34,11 +35,5 @@ enum _TagOrdering {
}
type Query {
Tag(
id: ID
first: Int
offset: Int
orderBy: [_TagOrdering]
filter: _TagFilter
): [Tag]
Tag(id: ID, first: Int, offset: Int, orderBy: [_TagOrdering], filter: _TagFilter): [Tag]
}

View File

@ -38,7 +38,8 @@ type User {
id: ID!
actorId: String
name: String
email: String! @cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email")
email: String!
@cypher(statement: "MATCH (this)-[:PRIMARY_EMAIL]->(e:EmailAddress) RETURN e.email")
slug: String!
avatar: Image @relation(name: "AVATAR_IMAGE", direction: "OUT")
deleted: Boolean
@ -64,65 +65,78 @@ type User {
emailNotificationSettings: [EmailNotificationSettings]! @neo4j_ignore
locale: String
friends: [User]! @relation(name: "FRIENDS", direction: "BOTH")
friendsCount: Int! @cypher(statement: "MATCH (this)<-[:FRIENDS]->(r:User) RETURN COUNT(DISTINCT r)")
friendsCount: Int!
@cypher(statement: "MATCH (this)<-[:FRIENDS]->(r:User) RETURN COUNT(DISTINCT r)")
following: [User]! @relation(name: "FOLLOWS", direction: "OUT")
followingCount: Int! @cypher(statement: "MATCH (this)-[:FOLLOWS]->(r:User) RETURN COUNT(DISTINCT r)")
followingCount: Int!
@cypher(statement: "MATCH (this)-[:FOLLOWS]->(r:User) RETURN COUNT(DISTINCT r)")
followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN")
followedByCount: Int! @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)")
followedByCount: Int!
@cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)")
# Is the currently logged in user following that user?
followedByCurrentUser: Boolean! @cypher(
statement: """
MATCH (this)<-[:FOLLOWS]-(u:User { id: $cypherParams.currentUserId})
RETURN COUNT(u) >= 1
"""
)
"Is the currently logged in user following that user?"
followedByCurrentUser: Boolean!
@cypher(
statement: "MATCH (this)<-[:FOLLOWS]-(u:User { id: $cypherParams.currentUserId}) RETURN COUNT(u) >= 1"
)
isBlocked: Boolean! @cypher(
statement: """
MATCH (this)<-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId})
RETURN COUNT(user) >= 1
"""
)
blocked: Boolean! @cypher(
statement: """
MATCH (this)-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId})
RETURN COUNT(user) >= 1
"""
)
isBlocked: Boolean!
@cypher(
statement: """
MATCH (this)<-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId})
RETURN COUNT(user) >= 1
"""
)
blocked: Boolean!
@cypher(
statement: """
MATCH (this)-[:BLOCKED]-(user:User {id: $cypherParams.currentUserId})
RETURN COUNT(user) >= 1
"""
)
isMuted: Boolean!
@cypher(
statement: """
MATCH (this)<-[:MUTED]-(user:User { id: $cypherParams.currentUserId})
RETURN COUNT(user) >= 1
"""
)
isMuted: Boolean! @cypher(
statement: """
MATCH (this)<-[:MUTED]-(user:User { id: $cypherParams.currentUserId})
RETURN COUNT(user) >= 1
"""
)
# contributions: [WrittenPost]!
# contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]!
# @cypher(
# statement: "MATCH (this)-[w:WROTE]->(p:Post) RETURN p as Post, w.timestamp as timestamp"
# )
contributions: [Post]! @relation(name: "WROTE", direction: "OUT")
contributionsCount: Int! @cypher(
statement: """
MATCH (this)-[:WROTE]->(r:Post)
WHERE NOT r.deleted = true AND NOT r.disabled = true
RETURN COUNT(r)
"""
)
contributionsCount: Int!
@cypher(
statement: """
MATCH (this)-[:WROTE]->(r:Post)
WHERE NOT r.deleted = true AND NOT r.disabled = true
RETURN COUNT(r)
"""
)
comments: [Comment]! @relation(name: "WROTE", direction: "OUT")
commentedCount: Int! @cypher(statement: "MATCH (this)-[:WROTE]->(:Comment)-[:COMMENTS]->(p:Post) WHERE NOT p.deleted = true AND NOT p.disabled = true RETURN COUNT(DISTINCT(p))")
commentedCount: Int!
@cypher(
statement: "MATCH (this)-[:WROTE]->(:Comment)-[:COMMENTS]->(p:Post) WHERE NOT p.deleted = true AND NOT p.disabled = true RETURN COUNT(DISTINCT(p))"
)
shouted: [Post]! @relation(name: "SHOUTED", direction: "OUT")
shoutedCount: Int! @cypher(statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)")
shoutedCount: Int!
@cypher(
statement: "MATCH (this)-[:SHOUTED]->(r:Post) WHERE NOT r.deleted = true AND NOT r.disabled = true RETURN COUNT(DISTINCT r)"
)
categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT")
# Badges
badgeVerification: Badge! @neo4j_ignore
badgeTrophies: [Badge]! @relation(name: "REWARDED", direction: "IN")
badgeTrophiesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)")
@ -133,22 +147,14 @@ type User {
"personal inviteCodes the user has generated"
inviteCodes: [InviteCode]! @neo4j_ignore
# inviteCodes: [InviteCode]! @relation(name: "GENERATED", direction: "OUT")
redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT")
emotions: [EMOTED]
activeCategories: [String] @cypher(
statement: """
MATCH (category:Category)
WHERE NOT ((this)-[:NOT_INTERESTED_IN]->(category))
RETURN collect(category.id)
"""
)
myRoleInGroup: GroupMemberRole
activeCategories: [String] @neo4j_ignore
}
input _UserFilter {
AND: [_UserFilter!]
OR: [_UserFilter!]
@ -203,7 +209,7 @@ type Query {
filter: _UserFilter
): [User]
availableRoles: [UserRole]!
availableRoles: [UserRole]!
mutedUsers: [User]
blockedUsers: [User]
currentUser: User!
@ -215,7 +221,7 @@ enum Deletable {
}
type Mutation {
UpdateUser (
UpdateUser(
id: ID!
name: String
email: String
@ -245,14 +251,14 @@ type Mutation {
switchUserRole(role: UserRole!, id: ID!): User
saveCategorySettings(activeCategories: [String]): Boolean
updateOnlineStatus(status: OnlineStatus!): Boolean!
requestPasswordReset(email: String!, locale: String!): Boolean!
resetPassword(email: String!, nonce: String!, newPassword: String!): Boolean!
changePassword(oldPassword: String!, newPassword: String!): String!
# Get a JWT Token for the given Email and password
"Get a JWT Token for the given Email and password"
login(email: String!, password: String!): String!
setTrophyBadgeSelected(slot: Int!, badgeId: ID): User

View File

@ -4,7 +4,5 @@ type UserData {
}
type Query {
userData(
id: ID
): UserData
userData(id: ID): UserData
}

View File

@ -397,6 +397,26 @@ const isAllowedToGenerateGroupInviteCode = rule({
).records[0].get('count')
})
const isAllowedToPinGroupPost = rule({
cache: 'no_cache',
})(async (_parent, args, context: Context) => {
if (!context.user) return false
return (
(
await context.database.query({
query: `
MATCH (post:Post{id: $args.id})-[:IN]->(group:Group)
MATCH (user:User{id: $user.id})-[membership:MEMBER_OF]->(group)
WHERE (membership.role IN ['admin', 'owner'])
RETURN toString(count(group)) as count
`,
variables: { user: context.user, args },
})
).records[0].get('count') === '1'
)
})
// Permissions
export default shield(
{
@ -485,6 +505,8 @@ export default shield(
VerifyEmailAddress: isAuthenticated,
pinPost: isAdmin,
unpinPost: isAdmin,
pinGroupPost: isAllowedToPinGroupPost,
unpinGroupPost: isAllowedToPinGroupPost,
pushPost: isAdmin,
unpushPost: isAdmin,
UpdateDonations: isAdmin,

View File

@ -24,6 +24,7 @@ jest.mock('@aws-sdk/lib-storage', () => {
const uploadMock = Upload as unknown as jest.Mock
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const createReadStream: FileUpload['createReadStream'] = (() => ({
pipe: () => ({
on: (_: unknown, callback: () => void) => callback(), // eslint-disable-line promise/prefer-await-to-callbacks
@ -32,6 +33,7 @@ const createReadStream: FileUpload['createReadStream'] = (() => ({
const input = {
uniqueFilename: 'unique-filename.jpg',
mimetype: 'image/jpeg',
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
createReadStream,
}

View File

@ -25,7 +25,9 @@ export const s3Service = (config: S3Config, prefix: string) => {
Bucket,
Key: s3Location,
ACL: ObjectCannedACL.public_read,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
ContentType: mimetype,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
Body: createReadStream(),
}
const command = new Upload({ client: s3, params })

View File

@ -57,6 +57,7 @@ export const TEST_CONFIG = {
INVITE_CODES_GROUP_PER_USER: 7,
CATEGORIES_ACTIVE: false,
MAX_PINNED_POSTS: 1,
MAX_GROUP_PINNED_POSTS: 1,
LANGUAGE_DEFAULT: 'en',
LOG_LEVEL: 'DEBUG',

File diff suppressed because it is too large Load Diff

View File

@ -11,4 +11,5 @@ NETWORK_NAME="Ocelot.social"
ASK_FOR_REAL_NAME=false
REQUIRE_LOCATION=false
REQUIRE_LOCATION=false
MAX_GROUP_PINNED_POSTS=1

View File

@ -1 +1 @@
v20.12.1
v25.3.0

View File

@ -1 +0,0 @@
nodejs 20.12.1

View File

@ -1,4 +1,4 @@
FROM node:25.4.0-alpine AS styleguide
FROM node:25.5.0-alpine AS styleguide
RUN apk --no-cache add git python3 make g++
RUN mkdir -p /app
WORKDIR /app
@ -6,7 +6,7 @@ COPY styleguide .
RUN yarn install --production=false --frozen-lockfile --non-interactive
RUN yarn run build:lib
FROM node:25.4.0-alpine AS base
FROM node:25.5.0-alpine AS base
LABEL org.label-schema.name="ocelot.social:webapp"
LABEL org.label-schema.description="Web Frontend of the Social Network Software ocelot.social"
LABEL org.label-schema.usage="https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/README.md"

View File

@ -8,7 +8,7 @@ LABEL org.label-schema.vendor="ocelot.social Community"
LABEL org.label-schema.schema-version="1.0"
LABEL maintainer="devops@ocelot.social"
FROM node:25.4.0-alpine AS styleguide
FROM node:25.5.0-alpine AS styleguide
RUN apk --no-cache add git python3 make g++
RUN mkdir -p /app
WORKDIR /app
@ -16,7 +16,7 @@ COPY styleguide .
RUN yarn install --production=false --frozen-lockfile --non-interactive
RUN yarn run build:lib
FROM node:25.4.0-alpine AS build
FROM node:25.5.0-alpine AS build
ENV NODE_ENV="production"
RUN apk --no-cache add git python3 make g++ bash jq
COPY --from=styleguide ./app/ /styleguide/

View File

@ -4,14 +4,16 @@
## Installation
For preparation we need Node and recommend to use [node version manager](https://github.com/nvm-sh/nvm) `nvm` to switch
For preparation you need a recent version of
[Node](https://nodejs.org/en/). We are using
`v25.3.0` and recommend to use [node version manager](https://github.com/nvm-sh/nvm) `nvm` to switch
between different local Node versions:
```bash
# install Node
# install Node using '.nvmrc' file
$ cd webapp
$ nvm install v20.12.1
$ nvm use v20.12.1
$ nvm install
$ nvm use
```
Install node dependencies with [yarn](https://yarnpkg.com/en/):

View File

@ -0,0 +1,823 @@
import { mount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import VTooltip from 'v-tooltip'
import Styleguide from '@@/'
import ContentMenu from './ContentMenu.vue'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(VTooltip)
localVue.use(Vuex)
let mocks
describe('ContentMenu.vue - Group', () => {
beforeEach(() => {
mocks = {
$t: jest.fn((str) => str),
$i18n: {
locale: () => 'en',
},
$router: {
push: jest.fn(),
},
$env: {
MAX_GROUP_PINNED_POSTS: 0,
},
}
})
const stubs = {
'router-link': {
template: '<span><slot /></span>',
},
}
const mutations = {
'modal/SET_OPEN': jest.fn(),
}
const getters = {
'auth/isModerator': () => false,
'auth/isAdmin': () => false,
'pinnedPosts/maxPinnedPosts': () => 1,
'pinnedPosts/currentlyPinnedPosts': () => 1,
}
const actions = {
'pinnedPosts/fetch': jest.fn(),
}
const openContentMenu = async (values = {}) => {
const store = new Vuex.Store({ mutations, getters, actions })
const wrapper = mount(ContentMenu, {
propsData: {
...values,
},
mocks,
store,
localVue,
stubs,
})
const menuToggle = wrapper.find('[data-test="content-menu-button"]')
await menuToggle.trigger('click')
return wrapper
}
describe('as group owner', () => {
const myRole = 'owner'
describe('when maxGroupPinnedPosts = 0', () => {
beforeEach(() => {
mocks.$env = {
MAX_GROUP_PINNED_POSTS: 0,
}
})
it('can not pin unpinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
},
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin'),
).toHaveLength(0)
})
it('can unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
},
},
],
])
})
})
describe('when maxPinnedPosts = 1', () => {
beforeEach(() => {
mocks.$env = {
MAX_GROUP_PINNED_POSTS: 1,
}
})
describe('when currentlyPinnedPostsCount = 0', () => {
const currentlyPinnedPostsCount = 0
it('pin unpinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupPin')
.at(0)
.trigger('click')
expect(wrapper.emitted('pinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
})
describe('when currentlyPinnedPostsCount = 1', () => {
const currentlyPinnedPostsCount = 1
it('pin unpinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupPin')
.at(0)
.trigger('click')
expect(wrapper.emitted('pinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
})
})
describe('when maxPinnedPosts = 2', () => {
beforeEach(() => {
mocks.$env = {
MAX_GROUP_PINNED_POSTS: 2,
}
})
describe('when currentlyPinnedPostsCount = 1', () => {
const currentlyPinnedPostsCount = 1
it('pin unpinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupPin')
.at(0)
.trigger('click')
expect(wrapper.emitted('pinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
})
describe('when currentlyPinnedPostsCount = 2', () => {
const currentlyPinnedPostsCount = 2
it('pin unpinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin')
.length,
).toEqual(0)
})
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
})
})
})
describe('as group admin', () => {
const myRole = 'admin'
describe('when maxGroupPinnedPosts = 0', () => {
beforeEach(() => {
mocks.$env = {
MAX_GROUP_PINNED_POSTS: 0,
}
})
it('can not pin unpinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
},
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin'),
).toHaveLength(0)
})
it('can unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
},
},
],
])
})
})
describe('when maxPinnedPosts = 1', () => {
beforeEach(() => {
mocks.$env = {
MAX_GROUP_PINNED_POSTS: 1,
}
})
describe('when currentlyPinnedPostsCount = 0', () => {
const currentlyPinnedPostsCount = 0
it('pin unpinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupPin')
.at(0)
.trigger('click')
expect(wrapper.emitted('pinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
})
describe('when currentlyPinnedPostsCount = 1', () => {
const currentlyPinnedPostsCount = 1
it('pin unpinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupPin')
.at(0)
.trigger('click')
expect(wrapper.emitted('pinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
})
})
describe('when maxPinnedPosts = 2', () => {
beforeEach(() => {
mocks.$env = {
MAX_GROUP_PINNED_POSTS: 2,
}
})
describe('when currentlyPinnedPostsCount = 1', () => {
const currentlyPinnedPostsCount = 1
it('pin unpinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupPin')
.at(0)
.trigger('click')
expect(wrapper.emitted('pinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
})
describe('when currentlyPinnedPostsCount = 2', () => {
const currentlyPinnedPostsCount = 2
it('pin unpinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin')
.length,
).toEqual(0)
})
it('unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
})
wrapper
.findAll('.ds-menu-item')
.filter((item) => item.text() === 'post.menu.groupUnpin')
.at(0)
.trigger('click')
expect(wrapper.emitted('unpinGroupPost')).toEqual([
[
{
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount,
},
},
],
])
})
})
})
})
describe('as group usual', () => {
const myRole = 'usual'
describe('when maxGroupPinnedPosts = 0', () => {
beforeEach(() => {
mocks.$env = {
MAX_GROUP_PINNED_POSTS: 0,
}
})
it('can not pin unpinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
},
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin'),
).toHaveLength(0)
})
it('can not unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
},
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupUnpin'),
).toHaveLength(0)
})
})
describe('when maxPinnedPosts = 1', () => {
beforeEach(() => {
mocks.$env = {
MAX_GROUP_PINNED_POSTS: 1,
}
})
it('can not pin unpinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: false,
group: {
myRole,
currentlyPinnedPostsCount: 0,
},
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupPin'),
).toHaveLength(0)
})
it('can not unpin pinned post', async () => {
const wrapper = await openContentMenu({
isOwner: false,
resourceType: 'contribution',
resource: {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
groupPinned: true,
group: {
myRole,
currentlyPinnedPostsCount: 1,
},
},
})
expect(
wrapper.findAll('.ds-menu-item').filter((item) => item.text() === 'post.menu.groupUnpin'),
).toHaveLength(0)
})
})
})
})

View File

@ -244,6 +244,23 @@ export default {
}
}
if (
this.resourceType === 'contribution' &&
this.resource.group &&
['admin', 'owner'].includes(this.resource.group.myRole) &&
(this.canBeGroupPinned || this.resource.groupPinned)
) {
routes.push({
label: this.resource.groupPinned
? this.$t(`post.menu.groupUnpin`)
: this.$t(`post.menu.groupPin`),
callback: () => {
this.$emit(this.resource.groupPinned ? 'unpinGroupPost' : 'pinGroupPost', this.resource)
},
icon: this.resource.groupPinned ? 'unlink' : 'link',
})
}
return routes
},
isModerator() {
@ -258,6 +275,15 @@ export default {
(this.maxPinnedPosts > 1 && this.currentlyPinnedPosts < this.maxPinnedPosts)
)
},
canBeGroupPinned() {
const maxGroupPinnedPosts = this.$env.MAX_GROUP_PINNED_POSTS
return (
maxGroupPinnedPosts === 1 ||
(maxGroupPinnedPosts > 1 &&
this.resource.group &&
this.resource.group.currentlyPinnedPostsCount < maxGroupPinnedPosts)
)
},
},
methods: {
openItem(route, toggleMenu) {

View File

@ -18,7 +18,7 @@
/>
</base-button>
</a>
<nuxt-link v-else :to="settings.toolTipIdent">
<nuxt-link v-else :to="settings.path">
<base-button
class="custom-button"
circle

View File

@ -52,10 +52,14 @@ describe('CtaJoinLeaveGroup.vue', () => {
mocks.$apollo.mutate = jest.fn().mockResolvedValue({
data: {
JoinGroup: {
id: 'g-123',
slug: 'group-123',
name: 'Group 123',
myRoleInGroup: 'usual',
user: {
id: 'g-123',
slug: 'group-123',
name: 'Group 123',
},
membership: {
role: 'usual',
},
},
},
})
@ -66,10 +70,14 @@ describe('CtaJoinLeaveGroup.vue', () => {
expect(wrapper.emitted().update).toEqual([
[
{
id: 'g-123',
slug: 'group-123',
name: 'Group 123',
myRoleInGroup: 'usual',
user: {
id: 'g-123',
slug: 'group-123',
name: 'Group 123',
},
membership: {
role: 'usual',
},
},
],
])

View File

@ -8,14 +8,22 @@ const propsData = {
groupId: 'group-id',
groupMembers: [
{
slug: 'owner',
id: 'owner',
myRoleInGroup: 'owner',
user: {
slug: 'owner',
id: 'owner',
},
membership: {
role: 'owner',
},
},
{
slug: 'user',
id: 'user',
myRoleInGroup: 'usual',
user: {
slug: 'user',
id: 'user',
},
membership: {
role: 'usual',
},
},
],
}
@ -30,9 +38,13 @@ const apolloMock = jest
.mockResolvedValue({
data: {
ChangeGroupMemberRole: {
slug: 'user',
id: 'user',
myRoleInGroup: 'admin',
user: {
slug: 'user',
id: 'user',
},
membership: {
role: 'admin',
},
},
},
})
@ -117,9 +129,11 @@ describe('GroupMember', () => {
apolloMock.mockRejectedValueOnce({ message: 'Oh no!!' }).mockResolvedValue({
data: {
RemoveUserFromGroup: {
slug: 'user',
id: 'user',
myRoleInGroup: null,
user: {
slug: 'user',
id: 'user',
},
membership: null,
},
},
})

View File

@ -7,21 +7,21 @@
<nuxt-link
:to="{
name: 'profile-id-slug',
params: { id: scope.row.id, slug: scope.row.slug },
params: { id: scope.row.user.id, slug: scope.row.user.slug },
}"
>
<profile-avatar :profile="scope.row" size="small" />
<profile-avatar :profile="scope.row.user" size="small" />
</nuxt-link>
</template>
<template #name="scope">
<nuxt-link
:to="{
name: 'profile-id-slug',
params: { id: scope.row.id, slug: scope.row.slug },
params: { id: scope.row.user.id, slug: scope.row.user.slug },
}"
>
<ds-text>
<b>{{ scope.row.name | truncate(20) }}</b>
<b>{{ scope.row.user.name | truncate(20) }}</b>
</ds-text>
</nuxt-link>
</template>
@ -29,37 +29,37 @@
<nuxt-link
:to="{
name: 'profile-id-slug',
params: { id: scope.row.id, slug: scope.row.slug },
params: { id: scope.row.user.id, slug: scope.row.user.slug },
}"
>
<ds-text>
<b>{{ `@${scope.row.slug}` | truncate(20) }}</b>
<b>{{ `@${scope.row.user.slug}` | truncate(20) }}</b>
</ds-text>
</nuxt-link>
</template>
<template #roleInGroup="scope">
<select
v-if="scope.row.myRoleInGroup !== 'owner'"
v-if="scope.row.membership.role !== 'owner'"
:options="['pending', 'usual', 'admin', 'owner']"
:value="`${scope.row.myRoleInGroup}`"
@change="changeMemberRole(scope.row.id, $event)"
:value="`${scope.row.membership.role}`"
@change="changeMemberRole(scope.row.user.id, $event)"
>
<option v-for="role in ['pending', 'usual', 'admin', 'owner']" :key="role" :value="role">
{{ $t(`group.roles.${role}`) }}
</option>
</select>
<ds-chip v-else color="primary">
{{ $t(`group.roles.${scope.row.myRoleInGroup}`) }}
{{ $t(`group.roles.${scope.row.membership.role}`) }}
</ds-chip>
</template>
<template #edit="scope">
<base-button
v-if="scope.row.myRoleInGroup !== 'owner'"
v-if="scope.row.membership.role !== 'owner'"
size="small"
primary
@click="
isOpen = true
userId = scope.row.id
userId = scope.row.user.id
"
>
{{ $t('group.removeMemberButton') }}

View File

@ -51,13 +51,17 @@ describe('PostTeaser', () => {
}
getters = {
'auth/isModerator': () => false,
'auth/isAdmin': () => false,
'auth/user': () => {
return {}
},
'categories/categoriesActive': () => false,
'pinnedPosts/maxPinnedPosts': () => 0,
'pinnedPosts/currentlyPinnedPosts': () => 0,
}
actions = {
'categories/init': jest.fn(),
'pinnedPosts/fetch': jest.fn().mockResolvedValue(),
}
})

View File

@ -112,6 +112,8 @@
:is-owner="isAuthor"
@pinPost="pinPost"
@unpinPost="unpinPost"
@pinGroupPost="pinGroupPost"
@unpinGroupPost="unpinGroupPost"
@pushPost="pushPost"
@unpushPost="unpushPost"
@toggleObservePost="toggleObservePost"
@ -172,6 +174,10 @@ export default {
type: Object,
default: () => {},
},
showGroupPinned: {
type: Boolean,
default: false,
},
},
mounted() {
const { image } = this.post
@ -203,10 +209,11 @@ export default {
)
},
isPinned() {
return this.post && this.post.pinned
return this.post && (this.post.pinned || (this.showGroupPinned && this.post.groupPinned))
},
ribbonText() {
if (this.post.pinned) return this.$t('post.pinned')
if (this.post && (this.post.pinned || (this.showGroupPinned && this.post.groupPinned)))
return this.$t('post.pinned')
if (this.post.postType[0] === 'Event') return this.$t('post.event')
return this.$t('post.name')
},
@ -229,6 +236,12 @@ export default {
unpinPost(post) {
this.$emit('unpinPost', post)
},
pinGroupPost(post) {
this.$emit('pinGroupPost', post)
},
unpinGroupPost(post) {
this.$emit('unpinGroupPost', post)
},
pushPost(post) {
this.$emit('pushPost', post)
},

View File

@ -15,11 +15,15 @@ const stubs = {
}
describe('SearchResults', () => {
let mocks, getters, actions, propsData, wrapper
let mocks, getters, propsData, wrapper
const Wrapper = () => {
const store = new Vuex.Store({
getters,
actions,
actions: {
'categories/init': jest.fn(),
'pinnedPosts/fetch': jest.fn(),
},
})
return mount(SearchResults, { mocks, localVue, propsData, store, stubs })
}
@ -35,9 +39,6 @@ describe('SearchResults', () => {
'auth/isModerator': () => false,
'categories/categoriesActive': () => false,
}
actions = {
'categories/init': jest.fn(),
}
propsData = {
pageSize: 12,
search: '',

View File

@ -45,9 +45,12 @@
<post-teaser
:post="post"
:width="{ base: '100%', md: '100%', xl: '50%' }"
:showGroupPinned="true"
@removePostFromList="posts = removePostFromList(post, posts)"
@pinPost="pinPost(post, refetchPostList)"
@unpinPost="unpinPost(post, refetchPostList)"
@pinGroupPost="pinGroupPost(post, refetchPostList)"
@unpinGroupPost="unpinGroupPost(post, refetchPostList)"
@pushPost="pushPost(post, refetchPostList)"
@unpushPost="unpushPost(post, refetchPostList)"
@toggleObservePost="

View File

@ -38,6 +38,9 @@ const options = {
NETWORK_NAME: process.env.NETWORK_NAME || 'Ocelot.social',
ASK_FOR_REAL_NAME: process.env.ASK_FOR_REAL_NAME === 'true' || false,
REQUIRE_LOCATION: process.env.REQUIRE_LOCATION === 'true' || false,
MAX_GROUP_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_GROUP_PINNED_POSTS))
? 1
: Number(process.env.MAX_GROUP_PINNED_POSTS),
}
const language = {

View File

@ -177,6 +177,40 @@ export default () => {
}
}
`,
pinGroupPost: gql`
mutation ($id: ID!) {
pinGroupPost(id: $id) {
id
title
slug
content
contentExcerpt
language
pinnedBy {
id
name
role
}
}
}
`,
unpinGroupPost: gql`
mutation ($id: ID!) {
unpinGroupPost(id: $id) {
id
title
slug
content
contentExcerpt
language
pinnedBy {
id
name
role
}
}
}
`,
pushPost: gql`
mutation ($id: ID!) {
pushPost(id: $id) {

View File

@ -47,12 +47,6 @@ export default (i18n) => {
...badges
}
}
group {
id
name
slug
groupType
}
}
}
`
@ -90,12 +84,6 @@ export const filterPosts = (i18n) => {
...location
...badges
}
group {
id
name
slug
groupType
}
}
}
`
@ -132,12 +120,6 @@ export const profilePagePosts = (i18n) => {
...location
...badges
}
group {
id
name
slug
groupType
}
}
}
`

View File

@ -27,7 +27,16 @@ export const post = gql`
}
pinnedAt
pinned
groupPinned
isObservedByMe
observingUsersCount
group {
id
name
slug
groupType
myRole
currentlyPinnedPostsCount
}
}
`

View File

@ -111,10 +111,14 @@ export const joinGroupMutation = () => {
return gql`
mutation ($groupId: ID!, $userId: ID!) {
JoinGroup(groupId: $groupId, userId: $userId) {
id
name
slug
myRoleInGroup
user {
id
name
slug
}
membership {
role
}
}
}
`
@ -124,10 +128,14 @@ export const leaveGroupMutation = () => {
return gql`
mutation ($groupId: ID!, $userId: ID!) {
LeaveGroup(groupId: $groupId, userId: $userId) {
id
name
slug
myRoleInGroup
user {
id
name
slug
}
membership {
role
}
}
}
`
@ -137,10 +145,14 @@ export const changeGroupMemberRoleMutation = () => {
return gql`
mutation ($groupId: ID!, $userId: ID!, $roleInGroup: GroupMemberRole!) {
ChangeGroupMemberRole(groupId: $groupId, userId: $userId, roleInGroup: $roleInGroup) {
id
name
slug
myRoleInGroup
user {
id
name
slug
}
membership {
role
}
}
}
`
@ -150,10 +162,14 @@ export const removeUserFromGroupMutation = () => {
return gql`
mutation ($groupId: ID!, $userId: ID!) {
RemoveUserFromGroup(groupId: $groupId, userId: $userId) {
id
name
slug
myRoleInGroup
user {
id
name
slug
}
membership {
role
}
}
}
`
@ -215,12 +231,16 @@ export const groupMembersQuery = () => {
query ($id: ID!, $first: Int, $offset: Int) {
GroupMembers(id: $id, first: $first, offset: $offset) {
id
name
slug
myRoleInGroup
avatar {
...imageUrls
user {
id
name
slug
avatar {
...imageUrls
}
}
membership {
role
}
}
}

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Beitrag löschen",
"edit": "Beitrag bearbeiten",
"groupPin": "Beitrag anheften (Gruppe)",
"groupPinnedSuccessfully": "Beitrag erfolgreich angeheftet!",
"groupUnpin": "Beitrag loslösen (Gruppe)",
"groupUnpinnedSuccessfully": "Angehefteten Beitrag erfolgreich losgelöst!",
"observe": "Beitrag beobachten",
"observedSuccessfully": "Du beobachtest diesen Beitrag!",
"pin": "Beitrag anheften",

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Delete post",
"edit": "Edit post",
"groupPin": "Pin post (Group)",
"groupPinnedSuccessfully": "Post pinned successfully!",
"groupUnpin": "Unpin post (Group)",
"groupUnpinnedSuccessfully": "Post unpinned successfully!",
"observe": "Observe post",
"observedSuccessfully": "You are now observing this post!",
"pin": "Pin post",

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Borrar contribución",
"edit": "Editar contribución",
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": "Observar contribución",
"observedSuccessfully": null,
"pin": "Anclar contribución",

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Supprimer le Post",
"edit": "Modifier le Post",
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": "Observer le Post",
"observedSuccessfully": null,
"pin": "Épingler le Post",

View File

@ -861,6 +861,10 @@
"menu": {
"delete": null,
"edit": null,
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": null,
"observedSuccessfully": null,
"pin": null,

View File

@ -861,6 +861,10 @@
"menu": {
"delete": null,
"edit": null,
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": null,
"observedSuccessfully": null,
"pin": null,

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Usuń wpis",
"edit": "Edytuj wpis",
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": null,
"observedSuccessfully": null,
"pin": null,

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Excluir publicação",
"edit": "Editar publicação",
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": "Observar publicação",
"observedSuccessfully": null,
"pin": "Fixar publicação",

View File

@ -861,6 +861,10 @@
"menu": {
"delete": "Удалить пост",
"edit": "Редактировать пост",
"groupPin": null,
"groupPinnedSuccessfully": null,
"groupUnpin": null,
"groupUnpinnedSuccessfully": null,
"observe": null,
"observedSuccessfully": null,
"pin": "Закрепить пост",

View File

@ -38,6 +38,36 @@ export default {
})
.catch((error) => this.$toast.error(error.message))
},
pinGroupPost(post, refetchPostList = () => {}) {
this.$apollo
.mutate({
mutation: PostMutations().pinGroupPost,
variables: {
id: post.id,
},
})
.then(() => {
this.$toast.success(this.$t('post.menu.groupPinnedSuccessfully'))
// this.storePinGroupPost()
refetchPostList()
})
.catch((error) => this.$toast.error(error.message))
},
unpinGroupPost(post, refetchPostList = () => {}) {
this.$apollo
.mutate({
mutation: PostMutations().unpinGroupPost,
variables: {
id: post.id,
},
})
.then(() => {
this.$toast.success(this.$t('post.menu.groupUnpinnedSuccessfully'))
// this.storeUnpinGroupPost()
refetchPostList()
})
.catch((error) => this.$toast.error(error.message))
},
pushPost(post, refetchPostList = () => {}) {
this.$apollo
.mutate({

Some files were not shown because too many files have changed in this diff Show More