Merge branch 'master' into log-errors

This commit is contained in:
Moriz Wahl 2025-06-26 17:55:06 +02:00 committed by GitHub
commit 941399ff2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 408 additions and 202 deletions

View File

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.1.7
uses: actions/checkout@09d2acae674a48949e3602304ab46fd20ae0c42f # v4.2.2
- name: Copy backend env file
run: |
@ -46,7 +46,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.1.7
uses: actions/checkout@09d2acae674a48949e3602304ab46fd20ae0c42f # v4.2.2
- name: Copy backend env file
run: |
@ -69,7 +69,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.1.7
uses: actions/checkout@09d2acae674a48949e3602304ab46fd20ae0c42f # v4.2.2
- name: Setup Node.js
uses: actions/setup-node@08f58d1471bff7f3a07d167b4ad7df25d5fcfcb6 # v4.4.0
with:
node-version-file: 'backend/.tool-versions'
cache: 'yarn'
- name: Copy env files
run: |
@ -107,6 +113,15 @@ jobs:
# run copies of the current job in parallel
job: [1, 2, 3, 4, 5, 6, 7, 8]
steps:
- name: Checkout code
uses: actions/checkout@09d2acae674a48949e3602304ab46fd20ae0c42f # v4.2.2
- name: Setup Node.js
uses: actions/setup-node@08f58d1471bff7f3a07d167b4ad7df25d5fcfcb6 # v4.4.0
with:
node-version-file: 'backend/.tool-versions'
cache: 'yarn'
- name: Restore cypress cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.0.2
with:
@ -172,7 +187,7 @@ jobs:
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.1.7
uses: actions/checkout@09d2acae674a48949e3602304ab46fd20ae0c42f # v4.2.2
- name: Full stack tests | cleanup cache
run: |

View File

@ -83,7 +83,6 @@
"nodemailer-html-to-text": "^3.2.0",
"preview-email": "^3.1.0",
"pug": "^3.0.3",
"request": "~2.88.2",
"sanitize-html": "~2.17.0",
"slug": "~9.1.0",
"trunc-html": "~1.1.2",

View File

@ -0,0 +1,11 @@
import { Integer, Node } from 'neo4j-driver'
export interface CategoryDbProperties {
createdAt: string
icon: string
id: string
name: string
slug: string
}
export type Category = Node<Integer, CategoryDbProperties>

View File

@ -0,0 +1,13 @@
import { Integer, Node } from 'neo4j-driver'
export interface CommentDbProperties {
content: string
contentExcerpt: string
createdAt: string
deleted: boolean
disabled: boolean
id: string
updatedAt: string
}
export type Comment = Node<Integer, CommentDbProperties>

View File

@ -0,0 +1,10 @@
import { Integer, Node } from 'neo4j-driver'
export interface EmailAddressDbProperties {
createdAt: string
email: string
nonce: string
verifiedAt: string
}
export type EmailAddress = Node<Integer, EmailAddressDbProperties>

View File

@ -0,0 +1,12 @@
import { Integer, Node } from 'neo4j-driver'
import { PostDbProperties } from './Post'
export interface EventDbProperties extends PostDbProperties {
eventIsOnline: boolean
eventLocationName: string
eventStart: string
eventVenue: string
}
export type Event = Node<Integer, EventDbProperties>

View File

@ -0,0 +1,19 @@
import { Integer, Node } from 'neo4j-driver'
export interface GroupDbProperties {
about: string
actionRadius: string
createdAt: string
deleted: boolean
description: string
descriptionExcerpt: string
disabled: boolean
groupType: string
id: string
locationName?: string
name: string
slug: string
updatedAt: string
}
export type Group = Node<Integer, GroupDbProperties>

View File

@ -0,0 +1,12 @@
import { Integer, Node } from 'neo4j-driver'
export interface ImageDbProperties {
alt: string
aspectRatio: number
createdAt: string
sensitive: boolean
type: string
url: string
}
export type Image = Node<Integer, ImageDbProperties>

View File

@ -0,0 +1,8 @@
import { Integer, Node } from 'neo4j-driver'
export interface InviteCodeDbProperties {
code: string
createdAt: string
}
export type InviteCode = Node<Integer, InviteCodeDbProperties>

View File

@ -0,0 +1,20 @@
import { Integer, Node } from 'neo4j-driver'
export interface LocationDbProperties {
id: string
lat: number
lng: number
name: string
nameDE: string
nameEN: string
nameES: string
nameFR: string
nameIT: string
nameNL: string
namePL: string
namePT: string
nameRU: string
type: string
}
export type Location = Node<Integer, LocationDbProperties>

View File

@ -0,0 +1,13 @@
import { Integer, Node } from 'neo4j-driver'
export interface MessageDbProperties {
content: string
createdAt: string
distributed: boolean
id: string
indexId: number
saved: boolean
seen: boolean
}
export type Message = Node<Integer, MessageDbProperties>

View File

@ -0,0 +1,21 @@
import { Integer, Node } from 'neo4j-driver'
export interface PostDbProperties {
clickedCount: number
content: string
contentExcerpt: string
createdAt: string
deleted: boolean
disabled: boolean
id: string
language: string
postType: string // this is a PostType[] in the graphql, mapped from the labels
slug: string
sortDate: string
title: string
updatedAt: string
viewedTeaserCount: number
}
export type Post = Node<Integer, PostDbProperties>
export type Article = Node<Integer, PostDbProperties>

View File

@ -0,0 +1,11 @@
import { Integer, Node } from 'neo4j-driver'
export interface ReportDbProperties {
closed: boolean
createdAt: string
id: string
rule: string
updatedAt: string
}
export type Report = Node<Integer, ReportDbProperties>

View File

@ -0,0 +1,10 @@
import { Integer, Node } from 'neo4j-driver'
export interface TagDbProperties {
deleted: boolean
disabled: boolean
id: string
updatedAt: string
}
export type Tag = Node<Integer, TagDbProperties>

View File

@ -6,27 +6,15 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable promise/avoid-new */
/* eslint-disable promise/prefer-await-to-callbacks */
/* eslint-disable n/no-unsupported-features/node-builtins */
import { UserInputError } from 'apollo-server'
import request from 'request'
import CONFIG from '@config/index'
const fetch = (url) => {
return new Promise((resolve, reject) => {
request(url, function (error, response, body) {
if (error) {
reject(error)
} else {
resolve(JSON.parse(body))
}
})
})
}
const locales = ['en', 'de', 'fr', 'nl', 'it', 'es', 'pt', 'pl', 'ru']
const REQUEST_TIMEOUT = 3000
const createLocation = async (session, mapboxData) => {
const data = {
id: mapboxData.id + (mapboxData.address ? `-${mapboxData.address}` : ''),
@ -78,74 +66,80 @@ export const createOrUpdateLocations = async (nodeLabel, nodeId, locationName, s
let locationId
if (locationName !== null) {
const res: any = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(
locationName,
)}.json?access_token=${
CONFIG.MAPBOX_TOKEN
}&types=region,place,country,address&language=${locales.join(',')}`,
)
try {
if (locationName !== null) {
const response: any = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(
locationName,
)}.json?access_token=${
CONFIG.MAPBOX_TOKEN
}&types=region,place,country,address&language=${locales.join(',')}`,
{
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
},
)
if (!res?.features?.[0]) {
throw new UserInputError('locationName is invalid')
}
const res = await response.json()
let data
res.features.forEach((item) => {
if (item.matching_place_name === locationName) {
data = item
if (!res?.features?.[0]) {
throw new UserInputError('locationName is invalid')
}
})
if (!data) {
data = res.features[0]
}
if (!data?.place_type?.length) {
throw new UserInputError('locationName is invalid')
}
let data
if (data.place_type.length > 1) {
data.id = 'region.' + data.id.split('.')[1]
}
await createLocation(session, data)
res.features.forEach((item) => {
if (item.matching_place_name === locationName) {
data = item
}
})
if (!data) {
data = res.features[0]
}
let parent = data
if (!data?.place_type?.length) {
throw new UserInputError('locationName is invalid')
}
if (parent.address) {
parent.id += `-${parent.address}`
}
if (data.place_type.length > 1) {
data.id = 'region.' + data.id.split('.')[1]
}
await createLocation(session, data)
if (data.context) {
for await (const ctx of data.context) {
await createLocation(session, ctx)
await session.writeTransaction((transaction) => {
return transaction.run(
`
let parent = data
if (parent.address) {
parent.id += `-${parent.address}`
}
if (data.context) {
for await (const ctx of data.context) {
await createLocation(session, ctx)
await session.writeTransaction((transaction) => {
return transaction.run(
`
MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId})
MERGE (child)<-[:IS_IN]-(parent)
RETURN child.id, parent.id
`,
{
parentId: parent.id,
childId: ctx.id,
},
)
})
parent = ctx
{
parentId: parent.id,
childId: ctx.id,
},
)
})
parent = ctx
}
}
locationId = data.id
} else {
locationId = 'non-existent-id'
}
locationId = data.id
} else {
locationId = 'non-existent-id'
}
// delete all current locations from node and add new location
await session.writeTransaction((transaction) => {
return transaction.run(
`
// delete all current locations from node and add new location
await session.writeTransaction((transaction) => {
return transaction.run(
`
MATCH (node:${nodeLabel} {id: $nodeId})
OPTIONAL MATCH (node)-[relationship:IS_IN]->(:Location)
DELETE relationship
@ -154,18 +148,29 @@ export const createOrUpdateLocations = async (nodeLabel, nodeId, locationName, s
MERGE (node)-[:IS_IN]->(location)
RETURN location.id, node.id
`,
{ nodeId, locationId },
)
})
{ nodeId, locationId },
)
})
} catch (error) {
throw new Error(error)
}
}
export const queryLocations = async ({ place, lang }) => {
const res: any = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${place}.json?access_token=${CONFIG.MAPBOX_TOKEN}&types=region,place,country&language=${lang}`,
)
// Return empty array if no location found or error occurred
if (!res?.features) {
return []
try {
const res: any = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${place}.json?access_token=${CONFIG.MAPBOX_TOKEN}&types=region,place,country&language=${lang}`,
{
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
},
)
const response = await res.json()
// Return empty array if no location found or error occurred
if (!response?.features) {
return []
}
return response.features
} catch (error) {
throw new Error(error)
}
return res.features
}

View File

@ -1,14 +1,7 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I see all the reported posts including the one from above', () => {
cy.intercept({
method: 'POST',
url: '/api',
hostname: 'localhost',
}).as('getReports')
cy.wait(['@getReports'],{ timeout: 30000 }).then((interception) => {
console.log('Cypress interception:', interception)
cy.wait('@reportsQuery', { timeout: 30000 }).then((interception) => {
cy.wrap(interception.response.statusCode).should('eq', 200)
cy.wrap(interception.request.body)
.should('have.property', 'query', `query ($orderBy: ReportOrdering, $first: Int, $offset: Int, $reviewed: Boolean, $closed: Boolean) {
@ -104,6 +97,6 @@ defineStep('I see all the reported posts including the one from above', () => {
})
cy.get('table tbody').within(() => {
cy.contains('tr', 'The Truth about the Holocaust')
cy.contains('tr', 'The Truth about the Holocaust').should('be.visible')
})
})
})

View File

@ -14,6 +14,18 @@ defineStep('I click on {string}', element => {
'Moderation': 'a[href="/moderation"]',
}
if (element === 'Moderation') {
cy.intercept({
method: 'POST',
url: '/api',
hostname: 'localhost',
}, (req) => {
if (req.body && req.body.query && req.body.query.includes('query ($orderBy: ReportOrdering')) {
req.alias = 'reportsQuery'
}
})
}
cy.get(elementSelectors[element])
.click()
.wait(750)

View File

@ -1,98 +1,91 @@
<template>
<div>
<client-only>
<vue-advanced-chat
:theme="theme"
:current-user-id="currentUser.id"
:room-id="computedRoomId"
:template-actions="JSON.stringify(templatesText)"
:menu-actions="JSON.stringify(menuActions)"
:text-messages="JSON.stringify(textMessages)"
:message-actions="messageActions"
:messages="JSON.stringify(messages)"
:messages-loaded="messagesLoaded"
:rooms="JSON.stringify(rooms)"
:room-actions="JSON.stringify(roomActions)"
:rooms-loaded="roomsLoaded"
:loading-rooms="loadingRooms"
show-files="true"
show-audio="true"
capture-files="true"
:height="'calc(100dvh - 190px)'"
:styles="JSON.stringify(computedChatStyle)"
:show-footer="true"
:responsive-breakpoint="responsiveBreakpoint"
:single-room="singleRoom"
show-reaction-emojis="false"
@send-message="sendMessage($event.detail[0])"
@fetch-messages="fetchMessages($event.detail[0])"
@fetch-more-rooms="fetchRooms"
@add-room="toggleUserSearch"
@show-demo-options="showDemoOptions = $event"
@open-user-tag="redirectToUserProfile($event.detail[0])"
@open-file="openFile($event.detail[0].file.file)"
>
<div
v-if="selectedRoom && selectedRoom.roomId"
slot="room-options"
class="chat-room-options"
>
<ds-flex v-if="singleRoom">
<ds-flex-item centered class="single-chat-bubble">
<nuxt-link :to="{ name: 'chat' }">
<base-button icon="expand" size="small" circle />
</nuxt-link>
</ds-flex-item>
<ds-flex-item centered>
<div class="vac-svg-button vac-room-options">
<slot name="menu-icon">
<base-button
icon="close"
size="small"
circle
@click="$emit('close-single-room', true)"
/>
</slot>
</div>
</ds-flex-item>
</ds-flex>
</div>
<div slot="room-header-avatar">
<div
v-if="selectedRoom && selectedRoom.avatar"
class="vac-avatar"
:style="{ 'background-image': `url('${selectedRoom.avatar}')` }"
/>
<div v-else-if="selectedRoom" class="vac-avatar">
<span class="initials">{{ getInitialsName(selectedRoom.roomName) }}</span>
<vue-advanced-chat
:theme="theme"
:current-user-id="currentUser.id"
:room-id="computedRoomId"
:template-actions="JSON.stringify(templatesText)"
:menu-actions="JSON.stringify(menuActions)"
:text-messages="JSON.stringify(textMessages)"
:message-actions="messageActions"
:messages="JSON.stringify(messages)"
:messages-loaded="messagesLoaded"
:rooms="JSON.stringify(rooms)"
:room-actions="JSON.stringify(roomActions)"
:rooms-loaded="roomsLoaded"
:loading-rooms="loadingRooms"
:media-preview-enabled="isSafari ? 'false' : 'true'"
show-files="true"
show-audio="true"
capture-files="true"
:height="'calc(100dvh - 190px)'"
:styles="JSON.stringify(computedChatStyle)"
:show-footer="true"
:responsive-breakpoint="responsiveBreakpoint"
:single-room="singleRoom"
show-reaction-emojis="false"
@send-message="sendMessage($event.detail[0])"
@fetch-messages="fetchMessages($event.detail[0])"
@fetch-more-rooms="fetchRooms"
@add-room="toggleUserSearch"
@show-demo-options="showDemoOptions = $event"
@open-user-tag="redirectToUserProfile($event.detail[0])"
@open-file="openFile($event.detail[0].file.file)"
>
<div v-if="selectedRoom && selectedRoom.roomId" slot="room-options" class="chat-room-options">
<ds-flex v-if="singleRoom">
<ds-flex-item centered class="single-chat-bubble">
<nuxt-link :to="{ name: 'chat' }">
<base-button icon="expand" size="small" circle />
</nuxt-link>
</ds-flex-item>
<ds-flex-item centered>
<div class="vac-svg-button vac-room-options">
<slot name="menu-icon">
<base-button
icon="close"
size="small"
circle
@click="$emit('close-single-room', true)"
/>
</slot>
</div>
</div>
</ds-flex-item>
</ds-flex>
</div>
<div
v-for="message in messages.filter((m) => m.isUploading)"
:slot="'message_' + message._id"
v-bind:key="message._id"
class="vac-format-message-wrapper"
>
<div class="markdown">
<p>{{ $t('chat.transmitting') }}</p>
</div>
</div>
<div slot="room-header-avatar">
<div
v-if="selectedRoom && selectedRoom.avatar"
class="vac-avatar"
:style="{ 'background-image': `url('${selectedRoom.avatar}')` }"
/>
<div v-else-if="selectedRoom" class="vac-avatar">
<span class="initials">{{ getInitialsName(selectedRoom.roomName) }}</span>
</div>
</div>
<div v-for="room in rooms" :slot="'room-list-avatar_' + room.id" :key="room.id">
<div
v-if="room.avatar"
class="vac-avatar"
:style="{ 'background-image': `url('${room.avatar}')` }"
/>
<div v-else class="vac-avatar">
<span class="initials">{{ getInitialsName(room.roomName) }}</span>
</div>
</div>
</vue-advanced-chat>
</client-only>
</div>
<div
v-for="message in messages.filter((m) => m.isUploading)"
:slot="'message_' + message._id"
v-bind:key="message._id"
class="vac-format-message-wrapper"
>
<div class="markdown">
<p>{{ $t('chat.transmitting') }}</p>
</div>
</div>
<div v-for="room in rooms" :slot="'room-list-avatar_' + room.id" :key="room.id">
<div
v-if="room.avatar"
class="vac-avatar"
:style="{ 'background-image': `url('${room.avatar}')` }"
/>
<div v-else class="vac-avatar">
<span class="initials">{{ getInitialsName(room.roomName) }}</span>
</div>
</div>
</vue-advanced-chat>
</template>
<script>
@ -228,6 +221,9 @@ export default {
return roomId
},
isSafari() {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
},
textMessages() {
return {
ROOMS_EMPTY: this.$t('chat.roomsEmpty'),
@ -516,6 +512,11 @@ export default {
openFile: async function (file) {
if (!file || !file.url) return
/* For videos, this function is called only on Safari.
We don't want to download video files when clicking on them. */
if (file.type.startsWith('video/')) return
/* To make the browser download the file instead of opening it, it needs to be
from the same origin or from local blob storage. So we fetch it first
and then create a download link from blob storage. */

View File

@ -44,7 +44,10 @@
</ds-chip>
</div>
<!-- group categories -->
<div class="categories" v-if="categoriesActive && group.categories.length > 0">
<div
class="categories"
v-if="categoriesActive && group.categories && group.categories.length > 0"
>
<category
v-for="category in group.categories"
:key="category.id"

View File

@ -53,7 +53,10 @@
class="footer"
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)"
>
<div class="categories" v-if="categoriesActive && post.categories.length > 0">
<div
class="categories"
v-if="categoriesActive && post.categories && post.categories.length > 0"
>
<category
v-for="category in post.categories"
:key="category.id"

View File

@ -107,19 +107,26 @@ export default {
this.cities = []
return
}
this.loadingGeo = true
const place = encodeURIComponent(value)
const lang = this.$i18n.locale()
try {
this.loadingGeo = true
const {
data: { queryLocations: result },
} = await this.$apollo.query({ query: queryLocations(), variables: { place, lang } })
const place = encodeURIComponent(value)
const lang = this.$i18n.locale()
this.cities = this.processLocationsResult(result)
this.loadingGeo = false
const {
data: { queryLocations: result },
} = await this.$apollo.query({ query: queryLocations(), variables: { place, lang } })
return this.cities.find((city) => city.value === value)
this.cities = this.processLocationsResult(result)
this.loadingGeo = false
return this.cities.find((city) => city.value === value)
} catch (error) {
this.$toast.error(error.message)
} finally {
this.loadingGeo = false
}
},
clearLocationName(event) {
event.target.value = ''

View File

@ -14,7 +14,9 @@
<modal />
</client-only>
<div v-if="getShowChat.showChat" class="chat-modul">
<chat singleRoom :roomId="getShowChat.roomID" @close-single-room="closeSingleRoom" />
<client-only>
<chat singleRoom :roomId="getShowChat.roomID" @close-single-room="closeSingleRoom" />
</client-only>
</div>
</div>
</template>

View File

@ -261,6 +261,10 @@ export default {
},
}
if (ctx.isClient) {
config.devtool = 'source-map'
}
config.resolve.alias['~@'] = path.resolve(__dirname, '/')
config.resolve.alias['@@'] = path.resolve(__dirname, '/')

View File

@ -5,12 +5,14 @@
@add-chat-room="addChatRoom"
@close-user-search="showUserSearch = false"
/>
<chat
:roomId="getShowChat.showChat ? getShowChat.roomID : null"
ref="chat"
@toggle-user-search="showUserSearch = !showUserSearch"
:show-room="showRoom"
/>
<client-only>
<chat
:roomId="getShowChat.showChat ? getShowChat.roomID : null"
ref="chat"
@toggle-user-search="showUserSearch = !showUserSearch"
:show-room="showRoom"
/>
</client-only>
</div>
</template>