diff --git a/backend/src/constants/subscriptions.ts b/backend/src/constants/subscriptions.ts index 160a6d0cc5..ab75d499ce 100644 --- a/backend/src/constants/subscriptions.ts +++ b/backend/src/constants/subscriptions.ts @@ -3,3 +3,5 @@ export const CHAT_MESSAGE_ADDED = 'CHAT_MESSAGE_ADDED' export const CHAT_MESSAGE_STATUS_UPDATED = 'CHAT_MESSAGE_STATUS_UPDATED' export const ROOM_UPDATED = 'ROOM_UPDATED' export const VIDEO_CALL_PARTICIPANT_COUNT_CHANGED = 'VIDEO_CALL_PARTICIPANT_COUNT_CHANGED' +export const GROUP_MEMBERSHIP_VISIBILITY_CHANGED = 'GROUP_MEMBERSHIP_VISIBILITY_CHANGED' +export const GROUP_SHOW_MEMBERS_CHANGED = 'GROUP_SHOW_MEMBERS_CHANGED' diff --git a/backend/src/db/migrations/20260629120000-add-show-on-profile-to-member-of.ts b/backend/src/db/migrations/20260629120000-add-show-on-profile-to-member-of.ts new file mode 100644 index 0000000000..5e1a532692 --- /dev/null +++ b/backend/src/db/migrations/20260629120000-add-show-on-profile-to-member-of.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { getDriver } from '@db/neo4j' + +export const description = + 'Add showOnProfile field to all existing MEMBER_OF relationships, defaulting to true. Controls whether a group membership is displayed on the user profile page.' + +export async function up(_next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + try { + await transaction.run( + ` + MATCH (user:User)-[membership:MEMBER_OF]->(group:Group) + WHERE membership.showOnProfile IS NULL + SET membership.showOnProfile = true + `, + ) + await transaction.commit() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + await session.close() + } +} + +export async function down(_next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + try { + await transaction.run( + ` + MATCH (user:User)-[membership:MEMBER_OF]->(group:Group) + REMOVE membership.showOnProfile + `, + ) + await transaction.commit() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + await session.close() + } +} diff --git a/backend/src/db/migrations/20260701120000-add-show-members-to-group.ts b/backend/src/db/migrations/20260701120000-add-show-members-to-group.ts new file mode 100644 index 0000000000..88c29ea26f --- /dev/null +++ b/backend/src/db/migrations/20260701120000-add-show-members-to-group.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { getDriver } from '@db/neo4j' + +export const description = + 'Add showMembers field to existing closed Group nodes, defaulting to false. Public and hidden groups ignore this field (always true / always false).' + +export async function up(_next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + try { + await transaction.run( + ` + MATCH (group:Group) + WHERE group.groupType = 'closed' AND group.showMembers IS NULL + SET group.showMembers = false + `, + ) + await transaction.commit() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + await session.close() + } +} + +export async function down(_next) { + const driver = getDriver() + const session = driver.session() + const transaction = session.beginTransaction() + try { + await transaction.run( + ` + MATCH (group:Group) + REMOVE group.showMembers + `, + ) + await transaction.commit() + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + await transaction.rollback() + // eslint-disable-next-line no-console + console.log('rolled back') + throw new Error(error) + } finally { + await session.close() + } +} diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts index 13a2fdd4f0..efe3363c27 100644 --- a/backend/src/db/seed.ts +++ b/backend/src/db/seed.ts @@ -306,6 +306,16 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] await jennyRostock.relateTo(trophyStarter, 'selected', { slot: 1 }) await jennyRostock.relateTo(trophyFlower, 'selected', { slot: 2 }) + for (const url of [ + 'https://t.me/jenny_rostock_test', + 'http://nsosp.org/de/Quanten-Fluss-Theorie', + 'http://nsosp.org/de/Superial-Zahlen', + 'http://nsosp.org/de/New-Soul-Of-Science-Project', + ]) { + const sm = await Factory.build('socialMedia', { url }) + await sm.relateTo(jennyRostock, 'ownedBy') + } + await huey.relateTo(trophyPanda, 'rewarded') await huey.relateTo(trophyTiger, 'rewarded') await huey.relateTo(trophyAlienship, 'rewarded') @@ -468,6 +478,10 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] locationName: 'France', }, }) + await database.write({ + query: `MATCH (group:Group {id: 'g1'}) SET group.showMembers = true`, + variables: {}, + }) await mutate({ mutation: JoinGroup, variables: { @@ -632,6 +646,418 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, }) + authenticatedUser = await jennyRostock.toJson() + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g3', + name: 'Quantum Flow Theory', + about: 'Exploring the Quantum Flow Theory as a new foundation for physics.', + description: `

What is Quantum Flow Theory?

Quantum Flow Theory proposes a new way of understanding the building blocks of nature — not as particles or waves, but as flows of quantised information.

Goals

We discuss research, share papers, and develop ideas together that challenge and extend current physical models.

`, + groupType: 'public', + actionRadius: 'global', + categoryIds: ['cat9', 'cat16'], + locationName: 'France', + }, + }) + await mutate({ + mutation: JoinGroup, + variables: { groupId: 'g3', userId: 'u1' }, + }) + await mutate({ + mutation: JoinGroup, + variables: { groupId: 'g3', userId: 'u4' }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g3', userId: 'u1', roleInGroup: 'usual' }, + }) + + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g4', + name: 'New Soul Of Science Project', + about: 'A project bridging science, philosophy, and the human soul.', + description: `

The Project

The New Soul Of Science Project (NSOSP) aims to develop a holistic scientific worldview that integrates consciousness, matter, and meaning into a coherent framework.

Topics

We explore superial numbers, quantum flow, and the foundations of a new natural philosophy.

`, + groupType: 'public', + actionRadius: 'global', + categoryIds: ['cat9', 'cat16'], + locationName: 'France', + }, + }) + await mutate({ + mutation: JoinGroup, + variables: { groupId: 'g4', userId: 'u4' }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g4', userId: 'u4', roleInGroup: 'usual' }, + }) + + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g5', + name: 'Superial Numbers', + about: 'Research group for a new kind of number beyond real and complex numbers.', + description: `

What are Superial Numbers?

Superial numbers extend the known number systems — real, complex, and hypercomplex — into a new domain that may help describe quantum phenomena and consciousness mathematically.

Members

This is a closed research group for people actively working on or studying superial number theory.

`, + groupType: 'closed', + actionRadius: 'continental', + categoryIds: ['cat9'], + locationName: 'France', + }, + }) + await mutate({ + mutation: JoinGroup, + variables: { groupId: 'g5', userId: 'u4' }, + }) + await mutate({ + mutation: JoinGroup, + variables: { groupId: 'g5', userId: 'u5' }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g5', userId: 'u4', roleInGroup: 'admin' }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g5', userId: 'u5', roleInGroup: 'usual' }, + }) + + // authenticatedUser is still jennyRostock (u3) + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g6', + name: 'Gradido – Wertschätzungseinheit', + about: + 'Eine Wertschätzungseinheit nach dem Vorbild der Natur für Wohlstand, Frieden und Freiheit.', + description: `

Was ist Gradido?

Gradido wurde aus über 20 Jahren Forschung an der Gradido-Akademie für Wirtschaftsbionik entwickelt. Es ist eine Wertschätzungseinheit, die darauf abzielt, „Wohlstand, Frieden und Freiheit für alle Menschen – im Einklang mit der Natur" zu schaffen.

Wie funktioniert es?

Das Dreifache Wohl bildet das ethische Fundament: das Wohl des Einzelnen, das Wohl der Gemeinschaft und das Wohl der Natur. Neue Gradidos werden bevölkerungsbasiert ausgegeben – ohne Schulden. Entscheidend: 50 % der Guthaben verfallen jährlich, was Stabilität gewährleistet und Blasen verhindert.

Wirtschaftsbionik

Das theoretische Fundament der Initiative ist die „Wirtschaftsbionik", die Prinzipien der Natur untersucht, die seit Milliarden von Jahren Fülle, Vielfalt und Balance erhalten.

`, + groupType: 'public', + actionRadius: 'global', + categoryIds: ['cat6', 'cat9', 'cat16'], + locationName: 'Germany', + }, + }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g6', userId: 'u1' } }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g6', userId: 'u4' } }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g6', userId: 'u1', roleInGroup: 'usual' }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g6', userId: 'u4', roleInGroup: 'usual' }, + }) + + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g7', + name: 'Gesundheit fängt im Boden an', + about: + 'Das Bodenmikrobiom als Grundlage menschlicher Gesundheit – von der Erde bis zum Darm.', + description: `

Unsere Mission

Das Projekt, geleitet von Ulrike B. Rapp (Agraringenieurin für regenerative Landwirtschaft), zielt darauf ab, „humus- und mikrobiomreiche Böden als Grundlage gesunden Lebens" in den Händen von Gartenanbauern, Landwirten und Gemeinschaftsgärten zu entwickeln und zu sichern.

Kerngedanke

Wachsende Belege deuten darauf hin, dass das Darmmikrobiom eine entscheidende Rolle bei psychischer Gesundheit, Immunerkrankungen, Allergien, Stoffwechselstörungen und Krebs spielt. Der Grundsatz „gesunder Boden – gesunde Pflanze – gesunder Mensch" erfährt durch die Mikrobiomforschung wissenschaftliche Bestätigung.

Aktivitäten

`, + groupType: 'public', + actionRadius: 'national', + categoryIds: ['cat12', 'cat14'], + locationName: 'Germany', + }, + }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g7', userId: 'u4' } }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g7', userId: 'u5' } }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g7', userId: 'u4', roleInGroup: 'usual' }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g7', userId: 'u5', roleInGroup: 'usual' }, + }) + + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g8', + name: 'Minuto – Komplementärwährung', + about: + 'Zeitgutscheine als dezentrales, regionales Zahlungsmittel für Leistungen und Waren.', + description: `

Was ist Minuto?

Minuto ist „ein Zahlungsmittel zum Selbermachen in Form von Zeitgutscheinen für qualitative Leistung" – ein dezentrales, regionales Zahlungssystem, das menschliche Fähigkeiten und Kooperation statt Konkurrenz betont.

So funktioniert es

Teilnehmende drucken Vorlagen aus, ergänzen persönliche Angaben und holen die Unterschriften zweier Bürgen. Es gibt keine zentrale Ausgabestelle – jeder ist seine eigene Zentralbank. Das System wirkt am besten in geografischen Gemeinschaften, wo Menschen sich leicht vernetzen können.

Einsatzmöglichkeiten

Gutscheine können für Dienstleistungen (Backen, Computerhilfe, Gartenarbeit, Transport) und Waren (handgefertigte Artikel, Gebrauchtes, Vermietung) getauscht werden.

`, + groupType: 'closed', + actionRadius: 'regional', + categoryIds: ['cat6', 'cat9'], + locationName: 'Stralsund, Germany', + }, + }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g8', userId: 'u4' } }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g8', userId: 'u4', roleInGroup: 'admin' }, + }) + + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g9', + name: 'IT4C – Technologie für Wandel', + about: + 'Freie Software und offene Prozesse im Dienst des Gemeinwohls und gesellschaftlichen Wandels.', + description: `

Wer wir sind

IT4C ist ein Netzwerk engagierter Softwareentwickler:innen, Designer:innen und Berater:innen, das „Initiativen, Organisationen und Bewegungen" begleitet, die sich für eine gerechte, nachhaltige und demokratische Welt einsetzen. Die Arbeitsweise ist „open, solidarisch, menschenzentriert".

Was wir tun

IT4C betrachtet Softwareentwicklung nicht als Selbstzweck, sondern als Vehikel für sozialen Wandel. Digitale Souveränität steht im Mittelpunkt – durch transparente, ethisch reflektierte und partizipative Entwicklungsprozesse.

Projekte

`, + groupType: 'public', + actionRadius: 'global', + categoryIds: ['cat13', 'cat15'], + locationName: 'Germany', + }, + }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g9', userId: 'u1' } }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g9', userId: 'u4' } }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g9', userId: 'u1', roleInGroup: 'usual' }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g9', userId: 'u4', roleInGroup: 'admin' }, + }) + + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g10', + name: 'Linux Werkstatt – Privatsphäre', + about: + 'Privatsphäre auf digitalen Geräten umsetzen – von Linux auf dem PC bis zu GrapheneOS auf dem Smartphone.', + description: `

Worum geht es?

Die Linux Werkstatt entstand aus Datenschutzvorträgen von Chriz Stein und hat inzwischen Teilnehmende in ganz Deutschland. Das dezentrale Netzwerk lokaler Teams teilt technisches Wissen und Best Practices rund um datenschutzfreundliche Betriebssysteme und Anwendungen.

Computer und Notebooks

Wir begleiten den Umstieg auf Linux-basierte Betriebssysteme und Open-Source-Alternativen zu proprietärer Software wie Microsoft Office.

Smartphones

Teams helfen dabei, datenschutzfokussierte Systeme wie GrapheneOS, /e/OS und LineageOS zu evaluieren und empfehlen alternative Apps für Messaging, E-Mail, Navigation und digitale Geldbörsen.

`, + groupType: 'closed', + actionRadius: 'national', + categoryIds: ['cat15', 'cat11'], + locationName: 'Germany', + }, + }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g10', userId: 'u4' } }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g10', userId: 'u5' } }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g10', userId: 'u4', roleInGroup: 'usual' }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g10', userId: 'u5', roleInGroup: 'usual' }, + }) + + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g11', + name: 'Terra Nova – Gemeinschaft & Biohotel', + about: + 'Ein Gemeinschaftsort an der Ostsee – Begegnungsraum, essbare Landschaft und Biohotel.', + description: `

Terra Nova auf Gut Nisdorf

18 km von Stralsund entfernt liegt auf einem 700 Jahre alten Gutshof das Terra-Nova-Projekt. Im Sommer ist es ein Biohotel für Familien und Naturliebende, in den anderen Jahreszeiten „eine Oase der Stille". Das Anwesen bietet Gärten, einen Naturteich und Waldgebiete mit legendären Sonnenuntergängen über den Bodden.

Unsere Vision

Wir entwickeln eine essbare Landschaft und einen Begegnungsort, an dem authentische menschliche Verbindung jenseits von Meinungen und Vorurteilen entstehen kann. Die Gründungsgemeinschaft vereint Expertise in Bau, Ökologie, Permakultur, Unternehmensberatung, Journalismus und sozialer Moderation.

Mitmachen

Praktikant:innen und Interessierte können lernen und gleichzeitig zu Bau-, Garten-, Küchen- und Konstruktionsprojekten beitragen.

`, + groupType: 'public', + actionRadius: 'regional', + categoryIds: ['cat1', 'cat12', 'cat10'], + locationName: 'Nisdorf, Germany', + }, + }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g11', userId: 'u4' } }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g11', userId: 'u6' } }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g11', userId: 'u4', roleInGroup: 'usual' }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g11', userId: 'u6', roleInGroup: 'usual' }, + }) + + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g12', + name: 'Lehren 2.0 – Beruf sinnvoll leben', + about: + 'Professionelle Entwicklung für Lehrkräfte: Wandel von der Defizit- zur Potenzialkultur.', + description: `

Das Programm

Lehren 2.0 ist ein inspirierender, kreativer Online-Kurs mit Präsenzterminen in vier Modulen für Lehrkräfte, Schulleitungen und Eltern an freien Schulen. Das Ziel: das bestehende Bildungssystem durch gleichwertige Beziehungen und Selbstverantwortung der Lernenden umgestalten.

Holistische Pädagogik

Im Mittelpunkt steht, dass „eine logopädagogische Haltung der Lehrperson eine entscheidende Rolle für die Bindung an Kinder" und ihre Lernentwicklung spielt. Der Lehrplan bietet Werkzeuge, die Erziehende dabei unterstützen, das Potenzial von Kindern durch bedeutungsvolle Pädagogik zu fördern.

Abschluss

Das Programm endet mit einer Zertifizierung durch einen Bildungsbrief.

`, + groupType: 'closed', + actionRadius: 'national', + categoryIds: ['cat3', 'cat17'], + locationName: 'Germany', + }, + }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g12', userId: 'u4' } }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g12', userId: 'u5' } }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g12', userId: 'u4', roleInGroup: 'usual' }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g12', userId: 'u5', roleInGroup: 'usual' }, + }) + + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g13', + name: 'Montessori Grundschule Nordzypern', + about: + 'Aufbau einer Montessori-Grundschule in Nordzypern für kindgerechtes, selbstbestimmtes Lernen.', + description: `

Das Projekt

Die Initiative entstand, als Uri Reick mit seiner Familie nach Nordzypern zog und nur staatliche Schulen oder britische Privatschulen vorfand. Gemeinsam mit Dincel Sukrettin, Leiter eines Montessori-Kindergartens, und zwei weiteren deutschen Familien startete er den Aufbau einer neuen Schule.

Pädagogik

Der geplante Lehrplan betont Montessori- und Freie-Lern-Ansätze bei gleichzeitiger Erfüllung staatlicher Anforderungen für die Anerkennung. Unterrichtssprachen sind Englisch, Türkisch und Deutsch; weitere Schwerpunkte sind Gartenanbau, Ernährung und digitale Kompetenz.

Zahlen & Ziele

Im ersten Jahr sollen 20–40 Kinder der Klassen 1–3 aufgenommen werden. Die Vision wächst auf bis zu 100 Schüler:innen in den Klassen 1–5 innerhalb von vier Jahren. Finanzielle Unterstützung durch Genossenschaft und Crowdfunding ist geplant.

`, + groupType: 'public', + actionRadius: 'continental', + categoryIds: ['cat7', 'cat8'], + locationName: 'North Cyprus', + }, + }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g13', userId: 'u4' } }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g13', userId: 'u4', roleInGroup: 'usual' }, + }) + + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g14', + name: 'Lebenswege – Potenzialentfaltung', + about: + 'Kreative Wegbegleitung durch Wissen, Tanz, Worte, Gestaltung und Lieder zur Entfaltung des Seelensinns.', + description: `

Die Initiative

Gegründet von Kersten Elisabeth Pfaff, Choreografin und Tanzpädagogin, fokussiert sich diese Initiative auf den „inneren Ausdruck des Menschen" durch multiple kreative Ausdrucksformen. Die Arbeit zielt darauf ab, menschliches Potenzial über verschiedene Modalitäten zugänglich zu machen.

Angebote

`, + groupType: 'public', + actionRadius: 'regional', + categoryIds: ['cat3', 'cat16', 'cat17'], + locationName: 'Germany', + }, + }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g14', userId: 'u1' } }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g14', userId: 'u4' } }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g14', userId: 'u1', roleInGroup: 'usual' }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g14', userId: 'u4', roleInGroup: 'usual' }, + }) + + await mutate({ + mutation: CreateGroup, + variables: { + id: 'g15', + name: 'Treffpunkt Genossenschaft', + about: 'Platz, um Ideen vorzustellen und sich aktiv im Netzwerk zu vernetzen.', + description: `

Was ist der Treffpunkt Genossenschaft?

Ein Begegnungsort für Menschen mit Ideen, die zu den Werten der Genossenschaft passen und Unterstützung bei der Umsetzung suchen oder sich aktiv im Netzwerk vernetzen möchten.

Wie es funktioniert

Interessierte stellen sich vor, indem sie ihren persönlichen Hintergrund, ihre motivierenden Ideen oder ihr Projektwerk teilen und Gemeinsamkeiten für die Zusammenarbeit benennen. Das Team meldet sich dann zur Vorbereitung der nächsten Treffen.

Wen wir suchen

`, + groupType: 'closed', + actionRadius: 'national', + categoryIds: ['cat9', 'cat0'], + locationName: 'Germany', + }, + }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g15', userId: 'u4' } }) + await mutate({ mutation: JoinGroup, variables: { groupId: 'g15', userId: 'u6' } }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g15', userId: 'u4', roleInGroup: 'usual' }, + }) + await mutate({ + mutation: ChangeGroupMemberRole, + variables: { groupId: 'g15', userId: 'u6', roleInGroup: 'usual' }, + }) + + // eslint-disable-next-line no-console + console.log('seed', 'group avatars') + for (const { groupId, url, alt, aspectRatio } of [ + { + groupId: 'g3', + url: 'http://nsosp.org/share/images/FrQFT/FrQFT_header_v01_3.jpg', + alt: 'Quantum Flow Theory', + aspectRatio: 1.78, + }, + { + groupId: 'g4', + url: 'http://nsosp.org/share/images/NSOSP/NSOSP_header_v01_9.jpg', + alt: 'New Soul Of Science Project', + aspectRatio: 1.78, + }, + { + groupId: 'g5', + url: 'https://nsosp.org/share/images/SN/SN_header_v04-01.jpg', + alt: 'Superial Numbers', + aspectRatio: 1.0, + }, + { + groupId: 'g6', + url: 'https://menschlichwirtschaften.de/wp-content/uploads/gradido_coin.png', + alt: 'Gradido Coin', + aspectRatio: 1.0, + }, + { + groupId: 'g7', + url: 'https://menschlichwirtschaften.de/wp-content/uploads/Mikrobiom-Biozyklische-Humuserde-gesund.jpg', + alt: 'Gesundheit im Boden', + aspectRatio: 1.5, + }, + { + groupId: 'g8', + url: 'https://menschlichwirtschaften.de/wp-content/uploads/pixabay-arttower-ancient-cropped-2000x500-1.jpg', + alt: 'Minuto Komplementärwährung', + aspectRatio: 4.0, + }, + { + groupId: 'g9', + url: 'https://menschlichwirtschaften.de/wp-content/uploads/IT4C-Website-Bild-ohne-Text.webp', + alt: 'IT4C Team', + aspectRatio: 1.5, + }, + { + groupId: 'g10', + url: 'https://menschlichwirtschaften.de/wp-content/uploads/Logo_Linux_Werkstatt.jpg', + alt: 'Linux Werkstatt Logo', + aspectRatio: 1.0, + }, + { + groupId: 'g11', + url: 'https://menschlichwirtschaften.de/wp-content/uploads/TerraNova-Rittergut.jpg', + alt: 'Terra Nova Rittergut', + aspectRatio: 1.5, + }, + { + groupId: 'g12', + url: 'https://menschlichwirtschaften.de/wp-content/uploads/Lehren-2_0-fortbildung-modular.jpg', + alt: 'Lehren 2.0 Fortbildung', + aspectRatio: 1.5, + }, + { + groupId: 'g13', + url: 'https://menschlichwirtschaften.de/wp-content/uploads/Gruppenfoto-e1738164285204.jpg', + alt: 'Montessori Nordzypern Gruppe', + aspectRatio: 1.33, + }, + { + groupId: 'g14', + url: 'https://menschlichwirtschaften.de/wp-content/uploads/Kersten-Pfaff-header-chateau-neu1.jpg', + alt: 'Lebenswege Potenzialentfaltung', + aspectRatio: 1.78, + }, + { + groupId: 'g15', + url: 'https://menschlichwirtschaften.de/wp-content/uploads/82tpeld0_e4-e1741358592420.jpg', + alt: 'Treffpunkt Genossenschaft', + aspectRatio: 1.5, + }, + ]) { + await database.write({ + query: ` + MATCH (group:Group {id: $groupId}) + MERGE (img:Image {url: $url}) + SET img.alt = $alt, img.aspectRatio = $aspectRatio, img.type = 'image/jpeg' + MERGE (group)-[:AVATAR_IMAGE]->(img) + `, + variables: { groupId, url, alt, aspectRatio }, + }) + } + authenticatedUser = await louie.toJson() await mutate({ mutation: CreatePost, diff --git a/backend/src/graphql/queries/groups/Group.gql b/backend/src/graphql/queries/groups/Group.gql index 0ed6944268..1be6014377 100644 --- a/backend/src/graphql/queries/groups/Group.gql +++ b/backend/src/graphql/queries/groups/Group.gql @@ -27,6 +27,7 @@ query Group($isMember: Boolean, $id: ID, $slug: String) { name } myRole + showMembers membersCount currentlyPinnedPostsCount inviteCodes { diff --git a/backend/src/graphql/queries/groups/SetGroupMembershipVisibility.gql b/backend/src/graphql/queries/groups/SetGroupMembershipVisibility.gql new file mode 100644 index 0000000000..d0f8b2844a --- /dev/null +++ b/backend/src/graphql/queries/groups/SetGroupMembershipVisibility.gql @@ -0,0 +1,6 @@ +mutation SetGroupMembershipVisibility($groupId: ID!, $showOnProfile: Boolean!) { + setGroupMembershipVisibility(groupId: $groupId, showOnProfile: $showOnProfile) { + role + showOnProfile + } +} diff --git a/backend/src/graphql/queries/groups/UpdateGroup.gql b/backend/src/graphql/queries/groups/UpdateGroup.gql index a2f8e63997..8f8313d57e 100644 --- a/backend/src/graphql/queries/groups/UpdateGroup.gql +++ b/backend/src/graphql/queries/groups/UpdateGroup.gql @@ -9,6 +9,7 @@ mutation UpdateGroup( $categoryIds: [ID] $avatar: ImageInput $locationName: String # empty string '' sets it to null + $showMembers: Boolean ) { UpdateGroup( id: $id @@ -21,6 +22,7 @@ mutation UpdateGroup( categoryIds: $categoryIds avatar: $avatar locationName: $locationName + showMembers: $showMembers ) { id name diff --git a/backend/src/graphql/queries/groups/UserGroups.gql b/backend/src/graphql/queries/groups/UserGroups.gql new file mode 100644 index 0000000000..3c0f8eda36 --- /dev/null +++ b/backend/src/graphql/queries/groups/UserGroups.gql @@ -0,0 +1,13 @@ +query UserGroups($id: ID!, $first: Int, $offset: Int) { + User(id: $id) { + id + groups(first: $first, offset: $offset) { + id + name + groupType + myRole + membersCount + postsCount + } + } +} diff --git a/backend/src/graphql/queries/users/UpdateUser.gql b/backend/src/graphql/queries/users/UpdateUser.gql index 9d7b0ca1f4..75c026930e 100644 --- a/backend/src/graphql/queries/users/UpdateUser.gql +++ b/backend/src/graphql/queries/users/UpdateUser.gql @@ -5,6 +5,9 @@ mutation UpdateUser( $about: String $allowEmbedIframes: Boolean $showShoutsPublicly: Boolean + $showPublicGroupsOnProfile: Boolean + $showClosedGroupsOnProfile: Boolean + $showHiddenGroupsOnProfile: Boolean $emailNotificationSettings: [EmailNotificationSettingsInput] $termsAndConditionsAgreedVersion: String $avatar: ImageInput @@ -18,6 +21,9 @@ mutation UpdateUser( about: $about allowEmbedIframes: $allowEmbedIframes showShoutsPublicly: $showShoutsPublicly + showPublicGroupsOnProfile: $showPublicGroupsOnProfile + showClosedGroupsOnProfile: $showClosedGroupsOnProfile + showHiddenGroupsOnProfile: $showHiddenGroupsOnProfile emailNotificationSettings: $emailNotificationSettings termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion avatar: $avatar diff --git a/backend/src/graphql/resolvers/groups.spec.ts b/backend/src/graphql/resolvers/groups.spec.ts index edca2ad592..c15ca573bb 100644 --- a/backend/src/graphql/resolvers/groups.spec.ts +++ b/backend/src/graphql/resolvers/groups.spec.ts @@ -1,3 +1,4 @@ +/// /* eslint-disable @typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ @@ -6,6 +7,12 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable jest/no-commented-out-tests */ +import { PubSub } from 'graphql-subscriptions' + +import { + GROUP_MEMBERSHIP_VISIBILITY_CHANGED, + GROUP_SHOW_MEMBERS_CHANGED, +} from '@constants/subscriptions' import Factory, { cleanDatabase } from '@db/factories' import ChangeGroupMemberRole from '@graphql/queries/groups/ChangeGroupMemberRole.gql' import CreateGroup from '@graphql/queries/groups/CreateGroup.gql' @@ -16,12 +23,16 @@ import JoinGroup from '@graphql/queries/groups/JoinGroup.gql' import LeaveGroup from '@graphql/queries/groups/LeaveGroup.gql' import muteGroupMutation from '@graphql/queries/groups/muteGroup.gql' import RemoveUserFromGroup from '@graphql/queries/groups/RemoveUserFromGroup.gql' +import SetGroupMembershipVisibility from '@graphql/queries/groups/SetGroupMembershipVisibility.gql' import unmuteGroupMutation from '@graphql/queries/groups/unmuteGroup.gql' import UpdateGroup from '@graphql/queries/groups/UpdateGroup.gql' +import UserGroups from '@graphql/queries/groups/UserGroups.gql' import CreatePost from '@graphql/queries/posts/CreatePost.gql' import Post from '@graphql/queries/posts/Post.gql' import { createApolloTestSetup } from '@root/test/helpers' +import groupsResolver from './groups' + import type { ApolloTestSetup } from '@root/test/helpers' import type { Context } from '@src/context' // import CONFIG from '@src/config' @@ -3292,6 +3303,22 @@ describe('in mode', () => { }) }) }) + + describe('showMembers', () => { + it('publishes groupShowMembersChanged event when showMembers is updated', async () => { + await expect( + mutate({ + mutation: UpdateGroup, + variables: { id: 'my-group', showMembers: true }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: expect.objectContaining({ id: 'my-group', myRole: 'owner' }), + }, + errors: undefined, + }) + }) + }) }) describe('groupType', () => { @@ -3742,6 +3769,56 @@ describe('in mode', () => { }) }) + describe('setGroupMembershipVisibility', () => { + let groupId: string + + beforeEach(async () => { + authenticatedUser = await user.toJson() + const result = await mutate({ + mutation: CreateGroup, + variables: { + name: 'Visibility Test Group', + about: 'A group for visibility tests', + description: + 'This is a test group for visibility purposes, with enough description length to pass validation', + groupType: 'public', + actionRadius: 'national', + categoryIds: ['cat9'], + }, + }) + groupId = result.data.CreateGroup.id + }) + + it('sets showOnProfile to false for a group member', async () => { + await expect( + mutate({ + mutation: SetGroupMembershipVisibility, + variables: { groupId, showOnProfile: false }, + }), + ).resolves.toMatchObject({ + data: { + setGroupMembershipVisibility: { + role: 'owner', + showOnProfile: false, + }, + }, + errors: undefined, + }) + }) + + it('throws for unauthenticated user', async () => { + authenticatedUser = null + await expect( + mutate({ + mutation: SetGroupMembershipVisibility, + variables: { groupId, showOnProfile: false }, + }), + ).resolves.toMatchObject({ + errors: [expect.objectContaining({ message: 'Not Authorized!' })], + }) + }) + }) + describe('UpdateGroup slug conflict', () => { beforeEach(async () => { authenticatedUser = await user.toJson() @@ -3825,5 +3902,182 @@ describe('in mode', () => { }) }) }) + + describe('User.groups postsCount resolver', () => { + beforeEach(async () => { + authenticatedUser = await user.toJson() + await mutate({ + mutation: CreateGroup, + variables: { + id: 'posts-count-group', + name: 'Posts Count Group', + about: 'A group to test postsCount', + description: + 'A test group with enough description length to pass the validation requirement check', + groupType: 'public', + actionRadius: 'national', + categoryIds: ['cat9'], + }, + }) + }) + + it('returns postsCount = 0 when the group has no posts', async () => { + const result = await query({ + query: UserGroups, + variables: { id: 'current-user' }, + }) + const group = result.data?.User?.[0]?.groups?.find( + (g: { id: string }) => g.id === 'posts-count-group', + ) + expect(group).toBeDefined() + expect(group.postsCount).toBe(0) + }) + + it('returns postsCount = 2 after two posts are created in the group', async () => { + await mutate({ + mutation: CreatePost, + variables: { + id: 'post-in-group-1', + title: 'First Post', + content: 'Content of first post', + postType: 'Article', + groupId: 'posts-count-group', + }, + }) + await mutate({ + mutation: CreatePost, + variables: { + id: 'post-in-group-2', + title: 'Second Post', + content: 'Content of second post', + postType: 'Article', + groupId: 'posts-count-group', + }, + }) + const result = await query({ + query: UserGroups, + variables: { id: 'current-user' }, + }) + const group = result.data?.User?.[0]?.groups?.find( + (g: { id: string }) => g.id === 'posts-count-group', + ) + expect(group).toBeDefined() + expect(group.postsCount).toBe(2) + }) + }) + + describe('User.groups ordering – shared groups first', () => { + let viewerUser + + beforeEach(async () => { + viewerUser = await Factory.build( + 'user', + { id: 'viewer-user', name: 'Viewer User' }, + { email: 'viewer@example.org', password: '1234' }, + ) + // Create two groups as the profile owner + authenticatedUser = await user.toJson() + await mutate({ + mutation: CreateGroup, + variables: { + id: 'group-not-shared', + name: 'Not Shared Group', + about: 'Viewer is not a member', + description: + 'A test group with enough description length to pass the validation requirement check', + groupType: 'public', + actionRadius: 'national', + categoryIds: ['cat9'], + }, + }) + await mutate({ + mutation: CreateGroup, + variables: { + id: 'group-shared', + name: 'Shared Group', + about: 'Viewer is also a member', + description: + 'A test group with enough description length to pass the validation requirement check', + groupType: 'public', + actionRadius: 'national', + categoryIds: ['cat9'], + }, + }) + // Viewer joins only the second group + authenticatedUser = await viewerUser.toJson() + await mutate({ + mutation: JoinGroup, + variables: { groupId: 'group-shared', userId: 'viewer-user' }, + }) + }) + + it('returns the shared group (myRole != null) before the non-shared group', async () => { + authenticatedUser = await viewerUser.toJson() + const result = await query({ + query: UserGroups, + variables: { id: 'current-user', first: 10, offset: 0 }, + }) + const groups: Array<{ id: string; myRole: string | null }> = + result.data?.User?.[0]?.groups ?? [] + expect(groups).toHaveLength(2) + // shared group must come first + expect(groups[0].id).toBe('group-shared') + expect(groups[0].myRole).not.toBeNull() + expect(groups[1].id).toBe('group-not-shared') + expect(groups[1].myRole).toBeNull() + }) + + it("myRole is null for the profile owner's group the viewer has not joined", async () => { + authenticatedUser = await viewerUser.toJson() + const result = await query({ + query: UserGroups, + variables: { id: 'current-user' }, + }) + const groups: Array<{ id: string; myRole: string | null }> = + result.data?.User?.[0]?.groups ?? [] + const notShared = groups.find((g) => g.id === 'group-not-shared') + expect(notShared?.myRole).toBeNull() + }) + }) + }) +}) + +describe('Subscription.groupShowMembersChanged filter', () => { + it('passes matching events through for authenticated users', async () => { + const pubsub = new PubSub() + const context = { pubsub, user: { id: 'u1' } } as unknown as Context + const iterator = groupsResolver.Subscription.groupShowMembersChanged.subscribe( + null, + { groupId: 'g1' }, + context, + null, + ) + const next = iterator.next() + await pubsub.publish(GROUP_SHOW_MEMBERS_CHANGED, { + groupShowMembersChanged: { groupId: 'g1' }, + }) + const { value } = await next + expect(value).toEqual({ groupShowMembersChanged: { groupId: 'g1' } }) + await iterator.return?.() + }) +}) + +describe('Subscription.groupMembershipVisibilityChanged filter', () => { + it('passes matching events through for authenticated users', async () => { + const pubsub = new PubSub() + const context = { pubsub, user: { id: 'u1' } } as unknown as Context + const iterator = groupsResolver.Subscription.groupMembershipVisibilityChanged.subscribe( + null, + { userId: 'u2' }, + context, + null, + ) + const next = iterator.next() + await pubsub.publish(GROUP_MEMBERSHIP_VISIBILITY_CHANGED, { + groupMembershipVisibilityChanged: { userId: 'u2' }, + }) + const { value } = await next + expect(value).toEqual({ groupMembershipVisibilityChanged: { userId: 'u2' } }) + await iterator.return?.() }) }) diff --git a/backend/src/graphql/resolvers/groups.ts b/backend/src/graphql/resolvers/groups.ts index eba0a47d91..6b8383182c 100644 --- a/backend/src/graphql/resolvers/groups.ts +++ b/backend/src/graphql/resolvers/groups.ts @@ -7,10 +7,15 @@ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ /* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable @typescript-eslint/no-use-before-define */ +import { withFilter } from 'graphql-subscriptions' import { v4 as uuid } from 'uuid' import { CATEGORIES_MIN, CATEGORIES_MAX } from '@constants/categories' import { DESCRIPTION_WITHOUT_HTML_LENGTH_MIN } from '@constants/groups' +import { + GROUP_MEMBERSHIP_VISIBILITY_CHANGED, + GROUP_SHOW_MEMBERS_CHANGED, +} from '@constants/subscriptions' import { ForbiddenError, UserInputError } from '@graphql/errors' import { removeHtmlTags } from '@middleware/helpers/cleanHtml' @@ -64,7 +69,7 @@ export default { ${(isMember === true && "WHERE membership IS NOT NULL AND (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])") || ''} ${(isMember === false && "WHERE membership IS NULL AND (group.groupType IN ['public', 'closed'])") || ''} ${(isMember === undefined && "WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])") || ''} - RETURN group {.*, myRole: membership.role} + RETURN group {.*, myRole: membership.role, showOnProfile: coalesce(membership.showOnProfile, true)} ORDER BY group.createdAt DESC ${first !== undefined && offset !== undefined ? 'SKIP toInteger($offset) LIMIT toInteger($first)' : ''} `, @@ -84,24 +89,47 @@ export default { }, GroupMembers: async (_object, params, context: Context, _resolveInfo) => { const { id: groupId, first = 25, offset = 0, includePending = false } = params + const viewerId = context.user?.id ?? '' const session = context.driver.session() try { return await session.readTransaction(async (txc) => { - const pendingFilter = includePending ? '' : "WHERE membership.role <> 'pending'" - const groupMemberCypher = ` - MATCH (user:User)-[membership:MEMBER_OF]->(:Group {id: $groupId}) - ${pendingFilter} - RETURN user {.*}, membership {.*} - SKIP toInteger($offset) LIMIT toInteger($first) - ` - const transactionResponse = await txc.run(groupMemberCypher, { - groupId, - first, - offset, - }) - return transactionResponse.records.map((record) => { - return { user: record.get('user'), membership: record.get('membership') } - }) + const memberCheckResult = await txc.run( + `MATCH (:User {id: $viewerId})-[m:MEMBER_OF]->(:Group {id: $groupId}) + WHERE m.role IN ['usual', 'admin', 'owner'] + RETURN m.role AS role`, + { viewerId, groupId }, + ) + const isMember = memberCheckResult.records.length > 0 + + let cypher: string + if (isMember) { + const pendingFilter = includePending ? '' : "AND membership.role <> 'pending'" + cypher = ` + MATCH (user:User)-[membership:MEMBER_OF]->(:Group {id: $groupId}) + WHERE true ${pendingFilter} + RETURN user {.*}, membership {.*} + SKIP toInteger($offset) LIMIT toInteger($first) + ` + } else { + cypher = ` + MATCH (group:Group {id: $groupId}) + WHERE ( + group.groupType = 'public' + OR (group.groupType = 'closed' AND coalesce(group.showMembers, false) = true) + ) + MATCH (user:User)-[membership:MEMBER_OF]->(group) + WHERE membership.role <> 'pending' + AND coalesce(membership.showOnProfile, true) = true + RETURN user {.*}, membership {.*} + SKIP toInteger($offset) LIMIT toInteger($first) + ` + } + + const result = await txc.run(cypher, { groupId, first, offset }) + return result.records.map((record) => ({ + user: record.get('user'), + membership: record.get('membership'), + })) }) } finally { await session.close() @@ -327,6 +355,11 @@ export default { }) // TODO: put in a middleware, see "CreateGroup", "UpdateUser" await createOrUpdateLocations('Group', params.id, params.locationName, session, context) + if ('showMembers' in params) { + void context.pubsub.publish(GROUP_SHOW_MEMBERS_CHANGED, { + groupShowMembersChanged: { groupId }, + }) + } return group } catch (error) { if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') @@ -472,6 +505,38 @@ export default { await session.close() } }, + setGroupMembershipVisibility: async (_parent, params, context: Context, _resolveInfo) => { + if (!context.user) { + throw new Error('Missing authenticated user.') + } + const { groupId, showOnProfile } = params + const userId = context.user.id + const session = context.driver.session() + try { + return await session.writeTransaction(async (transaction) => { + const result = await transaction.run( + ` + MATCH (user:User {id: $userId})-[membership:MEMBER_OF]->(group:Group {id: $groupId}) + WHERE membership.role IN ['usual', 'admin', 'owner'] + SET membership.showOnProfile = $showOnProfile + SET membership.updatedAt = toString(datetime()) + RETURN membership {.*} + `, + { userId, groupId, showOnProfile }, + ) + const [membership] = result.records.map((r) => r.get('membership')) + if (!membership) { + throw new UserInputError('User is not a member of this group') + } + void context.pubsub.publish(GROUP_MEMBERSHIP_VISIBILITY_CHANGED, { + groupMembershipVisibilityChanged: { userId }, + }) + return membership + }) + } finally { + await session.close() + } + }, unmuteGroup: async (_parent, params, context: Context, _resolveInfo) => { if (!context.user) { throw new Error('Missing authenticated user.') @@ -502,6 +567,57 @@ export default { } }, }, + User: { + groups: async (parent, args, context: Context, _resolveInfo) => { + const profileUserId = parent.id + const viewerId = context.user?.id + const isOwnProfile = profileUserId === viewerId + const first = args.first ?? 10 + const offset = args.offset ?? 0 + const session = context.driver.session() + try { + return await session.readTransaction(async (txc) => { + let cypher: string + if (isOwnProfile) { + cypher = ` + MATCH (profileUser:User {id: $profileUserId})-[membership:MEMBER_OF]->(group:Group) + WHERE membership.role IN ['usual', 'admin', 'owner'] + RETURN group {.*, myRole: membership.role, showOnProfile: coalesce(membership.showOnProfile, true)} + ORDER BY group.groupType ASC, group.createdAt DESC + SKIP toInteger($offset) LIMIT toInteger($first) + ` + } else { + cypher = ` + MATCH (profileUser:User {id: $profileUserId})-[membership:MEMBER_OF]->(group:Group) + WHERE membership.role IN ['usual', 'admin', 'owner'] + AND coalesce(membership.showOnProfile, true) = true + OPTIONAL MATCH (viewer:User {id: $viewerId})-[viewerMembership:MEMBER_OF]->(group) + WITH profileUser, membership, group, viewerMembership + WHERE ( + (group.groupType = 'public' AND coalesce(profileUser.showPublicGroupsOnProfile, true) = true) + OR (group.groupType = 'closed' AND coalesce(profileUser.showClosedGroupsOnProfile, true) = true) + OR ( + group.groupType = 'hidden' + AND coalesce(profileUser.showHiddenGroupsOnProfile, true) = true + AND viewerMembership IS NOT NULL + AND viewerMembership.role IN ['usual', 'admin', 'owner'] + ) + ) + RETURN group {.*, myRole: viewerMembership.role, showOnProfile: coalesce(membership.showOnProfile, true)} + ORDER BY + CASE WHEN viewerMembership IS NOT NULL AND viewerMembership.role IN ['usual', 'admin', 'owner'] THEN 0 ELSE 1 END ASC, + group.createdAt DESC + SKIP toInteger($offset) LIMIT toInteger($first) + ` + } + const result = await txc.run(cypher, { profileUserId, viewerId, first, offset }) + return result.records.map((r) => r.get('group')) + }) + } finally { + await session.close() + } + }, + }, Group: { myRole: async (parent, _args, context: Context, _resolveInfo) => { if (!parent.id) { @@ -538,6 +654,19 @@ export default { }) ).records.map((r) => r.get('inviteCodes')) }, + postsCount: 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 (post:Post)-[:IN]->(:Group {id: $group.id}) + WHERE NOT post.deleted AND NOT post.disabled + RETURN toString(count(post)) as count`, + variables: { group: parent }, + }) + return result.records[0].get('count') + }, currentlyPinnedPostsCount: async (parent, _args, context: Context, _resolveInfo) => { if (!parent.id) { throw new Error('Can not identify selected Group!') @@ -595,6 +724,42 @@ export default { } return parent.about }, + showMembers: (parent) => { + if (parent.groupType === 'public') return true + if (parent.groupType === 'hidden') return false + // closed: configurable by owner; default false when property not yet set on the node + return (parent.showMembers as boolean) ?? false + }, + }, + Subscription: { + groupMembershipVisibilityChanged: { + subscribe: withFilter( + (_parent, _args, context: Context) => + context.pubsub.asyncIterator(GROUP_MEMBERSHIP_VISIBILITY_CHANGED), + ( + payload: { groupMembershipVisibilityChanged: { userId: string } }, + args: { userId: string }, + context: Context, + ) => { + if (!context.user) return false + return payload.groupMembershipVisibilityChanged.userId === args.userId + }, + ), + }, + groupShowMembersChanged: { + subscribe: withFilter( + (_parent, _args, context: Context) => + context.pubsub.asyncIterator(GROUP_SHOW_MEMBERS_CHANGED), + ( + payload: { groupShowMembersChanged: { groupId: string } }, + args: { groupId: string }, + context: Context, + ) => { + if (!context.user) return false + return payload.groupShowMembersChanged.groupId === args.groupId + }, + ), + }, }, } diff --git a/backend/src/graphql/resolvers/users.spec.ts b/backend/src/graphql/resolvers/users.spec.ts index 4a3460cd80..6508ff012f 100644 --- a/backend/src/graphql/resolvers/users.spec.ts +++ b/backend/src/graphql/resolvers/users.spec.ts @@ -392,6 +392,14 @@ describe('UpdateUser', () => { }) }) }) + + it('publishes group membership visibility event when group visibility fields are updated', async () => { + variables = { ...variables, showHiddenGroupsOnProfile: true } + await expect(mutate({ mutation: UpdateUser, variables })).resolves.toMatchObject({ + data: { UpdateUser: expect.objectContaining({ id: 'u47' }) }, + errors: undefined, + }) + }) }) }) diff --git a/backend/src/graphql/resolvers/users.ts b/backend/src/graphql/resolvers/users.ts index b96b7cab0d..eec3833eda 100644 --- a/backend/src/graphql/resolvers/users.ts +++ b/backend/src/graphql/resolvers/users.ts @@ -8,6 +8,7 @@ import { neo4jgraphql } from 'neo4j-graphql-js' import { TROPHY_BADGES_SELECTED_MAX } from '@constants/badges' +import { GROUP_MEMBERSHIP_VISIBILITY_CHANGED } from '@constants/subscriptions' import { getNeode } from '@db/neo4j' import { UserInputError, ForbiddenError } from '@graphql/errors' @@ -310,6 +311,15 @@ export default { }) // TODO: put in a middleware, see "CreateGroup", "UpdateGroup" await createOrUpdateLocations('User', params.id, params.locationName, session, context) + if ( + 'showPublicGroupsOnProfile' in params || + 'showClosedGroupsOnProfile' in params || + 'showHiddenGroupsOnProfile' in params + ) { + void context.pubsub.publish(GROUP_MEMBERSHIP_VISIBILITY_CHANGED, { + groupMembershipVisibilityChanged: { userId: params.id }, + }) + } return user } finally { await session.close() @@ -686,6 +696,9 @@ export default { 'termsAndConditionsAgreedAt', 'allowEmbedIframes', 'showShoutsPublicly', + 'showPublicGroupsOnProfile', + 'showClosedGroupsOnProfile', + 'showHiddenGroupsOnProfile', 'locale', ], boolean: { diff --git a/backend/src/graphql/types/type/Group.gql b/backend/src/graphql/types/type/Group.gql index 2daa4eb7d8..cf9803b42a 100644 --- a/backend/src/graphql/types/type/Group.gql +++ b/backend/src/graphql/types/type/Group.gql @@ -54,6 +54,10 @@ type Group { "The current user's role in this group, or null if they are not a member." myRole: GroupMemberRole + "Whether the profile owner has chosen to show this group on their public profile." + showOnProfile: Boolean + "For closed groups: whether non-members can see the member list." + showMembers: Boolean posts: [Post] @relation(name: "IN", direction: "IN") "Whether the current user has muted this group." @@ -67,6 +71,8 @@ type Group { "Number of posts currently pinned within this group." currentlyPinnedPostsCount: Int! @neo4j_ignore + "Total number of non-deleted, non-disabled posts in this group." + postsCount: Int! @neo4j_ignore } "A group member paired with their membership relationship (role, join date)." @@ -137,6 +143,7 @@ type Mutation { "Empty string '' clears the location (sets it to null)." locationName: String + showMembers: Boolean ): Group "Update a group's settings. Restricted to admins/owners of the group." @@ -154,6 +161,7 @@ type Mutation { avatar: ImageInput # test this as result "Empty string '' clears the location (sets it to null)." locationName: String + showMembers: Boolean ): Group # DeleteGroup(id: ID!): Group @@ -174,4 +182,22 @@ type Mutation { muteGroup(groupId: ID!): Group "Unmute a previously muted group. Requires membership." unmuteGroup(groupId: ID!): Group + + "Toggle whether a group membership is shown on the current user's profile." + setGroupMembershipVisibility(groupId: ID!, showOnProfile: Boolean!): MEMBER_OF +} + +type GroupMembershipVisibilityChanged { + userId: ID! +} + +type GroupShowMembersChanged { + groupId: ID! +} + +type Subscription { + "Fires when a user changes which group memberships are visible on their profile." + groupMembershipVisibilityChanged(userId: ID!): GroupMembershipVisibilityChanged! + "Fires when a closed group's showMembers setting changes." + groupShowMembersChanged(groupId: ID!): GroupShowMembersChanged! } diff --git a/backend/src/graphql/types/type/MEMBER_OF.gql b/backend/src/graphql/types/type/MEMBER_OF.gql index 8bcbe0422c..5a213d790e 100644 --- a/backend/src/graphql/types/type/MEMBER_OF.gql +++ b/backend/src/graphql/types/type/MEMBER_OF.gql @@ -3,4 +3,5 @@ type MEMBER_OF { createdAt: String! updatedAt: String! role: GroupMemberRole! + showOnProfile: Boolean } diff --git a/backend/src/graphql/types/type/User.gql b/backend/src/graphql/types/type/User.gql index 58c0f9ba99..9775350390 100644 --- a/backend/src/graphql/types/type/User.gql +++ b/backend/src/graphql/types/type/User.gql @@ -80,6 +80,12 @@ type User { allowEmbedIframes: Boolean "Whether the user's shouts are shown publicly on their profile." showShoutsPublicly: Boolean + "Whether the user's public group memberships are shown on their profile." + showPublicGroupsOnProfile: Boolean + "Whether the user's closed group memberships are shown on their profile." + showClosedGroupsOnProfile: Boolean + "Whether the user's hidden group memberships are shown on their profile." + showHiddenGroupsOnProfile: Boolean "The user's email notification settings. Visible only to the owner." emailNotificationSettings: [EmailNotificationSettings]! @neo4j_ignore "The user's preferred UI language." @@ -174,6 +180,9 @@ type User { "Category slugs the user has activated as their content filter." activeCategories: [String] @neo4j_ignore + + "Groups this user is a member of. Ordered by shared membership with the viewer first." + groups(first: Int, offset: Int): [Group] @neo4j_ignore } input _UserFilter { @@ -263,6 +272,9 @@ type Mutation { termsAndConditionsAgreedAt: String allowEmbedIframes: Boolean showShoutsPublicly: Boolean + showPublicGroupsOnProfile: Boolean + showClosedGroupsOnProfile: Boolean + showHiddenGroupsOnProfile: Boolean emailNotificationSettings: [EmailNotificationSettingsInput] locale: String ): User diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index 7642ea2cad..a33f1f9994 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -159,12 +159,12 @@ const isAllowedSeeingGroupMembers = rule({ }) try { const { member, group } = await readTxPromise + const isMember = !!member && ['usual', 'admin', 'owner'].includes(member.myRoleInGroup) return ( !!group && (group.groupType === 'public' || - (['closed', 'hidden'].includes(group.groupType) && - !!member && - ['usual', 'admin', 'owner'].includes(member.myRoleInGroup))) + (['closed', 'hidden'].includes(group.groupType) && isMember) || + (group.groupType === 'closed' && group.showMembers === true)) ) } catch (error) { throw new Error(error) @@ -683,6 +683,7 @@ export default shield( toggleObservePost: isAuthenticated, muteGroup: and(isAuthenticated, isMemberOfGroup), unmuteGroup: and(isAuthenticated, isMemberOfGroup), + setGroupMembershipVisibility: and(isAuthenticated, isMemberOfGroup), setTrophyBadgeSelected: isAuthenticated, resetTrophyBadgesSelected: isAuthenticated, }, diff --git a/webapp/components/ContentMenu/GroupContentMenu.vue b/webapp/components/ContentMenu/GroupContentMenu.vue index 92ac6585a8..77f4723a11 100644 --- a/webapp/components/ContentMenu/GroupContentMenu.vue +++ b/webapp/components/ContentMenu/GroupContentMenu.vue @@ -42,6 +42,7 @@ import { OsButton, OsIcon, OsMenu, OsMenuItem } from '@ocelot-social/ui' import { iconRegistry } from '~/utils/iconRegistry' import Dropdown from '~/components/Dropdown' +import { setGroupMembershipVisibilityMutation } from '~/graphql/UserGroups' export default { name: 'GroupContentMenu', @@ -63,7 +64,24 @@ export default { group: { type: Object, required: true }, placement: { type: String, default: 'bottom-end' }, }, + data() { + return { + localShowOnProfile: null, + } + }, + created() { + this.icons = iconRegistry + this.localShowOnProfile = this.group.showOnProfile !== false + }, + watch: { + 'group.showOnProfile'(newVal) { + this.localShowOnProfile = newVal !== false + }, + }, computed: { + isMember() { + return ['usual', 'admin', 'owner'].includes(this.group.myRole) + }, routes() { const routes = [] @@ -96,6 +114,26 @@ export default { } } + if (this.isMember) { + if (this.localShowOnProfile) { + routes.push({ + label: this.$t('group.contentMenu.hideFromProfile'), + icon: this.icons.eyeSlash, + callback: () => { + this.toggleProfileVisibility(false) + }, + }) + } else { + routes.push({ + label: this.$t('group.contentMenu.showOnProfile'), + icon: this.icons.eye, + callback: () => { + this.toggleProfileVisibility(true) + }, + }) + } + } + if (this.group.myRole === 'owner') { routes.push({ label: this.$t('admin.settings.name'), @@ -112,9 +150,6 @@ export default { return routes }, }, - created() { - this.icons = iconRegistry - }, methods: { openItem(route, toggleMenu) { if (route.callback) { @@ -124,6 +159,19 @@ export default { } toggleMenu() }, + async toggleProfileVisibility(showOnProfile) { + try { + await this.$apollo.mutate({ + mutation: setGroupMembershipVisibilityMutation(), + variables: { groupId: this.group.id, showOnProfile }, + }) + this.localShowOnProfile = showOnProfile + this.$emit('profileVisibilityChanged', this.group.id, showOnProfile) + this.$toast.success(this.$t('group.contentMenu.profileVisibilityUpdated')) + } catch (error) { + this.$toast.error(error.message) + } + }, }, } diff --git a/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap b/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap index 8a690a0739..86d8a5047b 100644 --- a/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap +++ b/webapp/components/ContentMenu/__snapshots__/GroupContentMenu.spec.js.snap @@ -92,6 +92,32 @@ exports[`GroupContentMenu renders as groupProfile when I am the owner 1`] = ` +
  • + + + + group.contentMenu.hideFromProfile + + +
  • diff --git a/webapp/components/Group/GroupForm.spec.js b/webapp/components/Group/GroupForm.spec.js index 2bcce274fc..2544a7ff49 100644 --- a/webapp/components/Group/GroupForm.spec.js +++ b/webapp/components/Group/GroupForm.spec.js @@ -249,4 +249,39 @@ describe('GroupForm', () => { expect(hiddenOption.attributes('disabled')).toBeUndefined() }) }) + + describe('effectiveShowMembers', () => { + const mountWithType = (groupType, showMembers = false) => + mount(GroupForm, { + propsData: { update: false, group: { groupType, showMembers } }, + mocks: { $t: jest.fn(), $can: () => true }, + localVue, + stubs, + store, + }) + + it('returns true for public groups regardless of showMembers', () => { + expect(mountWithType('public', false).vm.effectiveShowMembers).toBe(true) + expect(mountWithType('public', true).vm.effectiveShowMembers).toBe(true) + }) + + it('returns false for hidden groups regardless of showMembers', () => { + expect(mountWithType('hidden', true).vm.effectiveShowMembers).toBe(false) + expect(mountWithType('hidden', false).vm.effectiveShowMembers).toBe(false) + }) + + it('returns the actual showMembers value for closed groups', () => { + expect(mountWithType('closed', false).vm.effectiveShowMembers).toBe(false) + expect(mountWithType('closed', true).vm.effectiveShowMembers).toBe(true) + }) + + it('disables the checkbox when groupType is not closed', async () => { + const wrapper = mountWithType('public') + expect(wrapper.find('#show-members').attributes('disabled')).toBeDefined() + await wrapper.vm.$set(wrapper.vm.formData, 'groupType', 'closed') + expect(wrapper.find('#show-members').attributes('disabled')).toBeUndefined() + await wrapper.vm.$set(wrapper.vm.formData, 'groupType', 'hidden') + expect(wrapper.find('#show-members').attributes('disabled')).toBeDefined() + }) + }) }) diff --git a/webapp/components/Group/GroupForm.vue b/webapp/components/Group/GroupForm.vue index da7f36a266..841dda41ce 100644 --- a/webapp/components/Group/GroupForm.vue +++ b/webapp/components/Group/GroupForm.vue @@ -24,7 +24,7 @@ v-if="update" :label="$t('group.labelSlug')" model="slug" - icon="at" + prefix="&" :placeholder="`${$t('group.labelSlug')} …`" > @@ -65,6 +65,20 @@ /> + +
    + + +
    + category.id) : [] + const effectiveShowMembersInitial = + groupType === 'public' ? true : groupType === 'hidden' ? false : (showMembers ?? false) return { disabled: false, loading: false, @@ -233,6 +258,7 @@ export default { description: description || '', actionRadius: actionRadius || '', locationName: locationName || '', + showMembers: effectiveShowMembersInitial, }, formData: { name: name || '', @@ -243,6 +269,7 @@ export default { locationName: locationName || '', actionRadius: actionRadius || '', categoryIds: [...initialCategoryIds], + showMembers: showMembers ?? false, }, formSchema: { name: { required: true, min: NAME_LENGTH_MIN, max: NAME_LENGTH_MAX }, @@ -309,6 +336,11 @@ export default { canCreateSelectedGroup() { return this.update || this.$can(`group.create_${this.formData.groupType}`) }, + effectiveShowMembers() { + if (this.formData.groupType === 'public') return true + if (this.formData.groupType === 'hidden') return false + return this.formData.showMembers + }, disableButtonByUpdate() { if (!this.update) return true return ( @@ -319,7 +351,8 @@ export default { this.savedBaseline.description === this.formData.description && this.savedBaseline.actionRadius === this.formData.actionRadius && this.sameLocation && - this.sameCategories + this.sameCategories && + this.savedBaseline.showMembers === this.effectiveShowMembers ) }, }, @@ -371,6 +404,7 @@ export default { actionRadius, locationName: this.formLocationName, categoryIds, + showMembers: this.effectiveShowMembers, } const done = (success) => { this.loading = false @@ -383,6 +417,7 @@ export default { description: this.formData.description, actionRadius: this.formData.actionRadius, locationName: this.formLocationName, + showMembers: this.effectiveShowMembers, } this.initialCategoryIds = [...this.formData.categoryIds] } @@ -417,6 +452,19 @@ export default { display: flex; flex-direction: column; + > .show-members-control { + display: flex; + flex-direction: row; + align-items: center; + gap: $space-x-small; + margin-top: -$space-base - $space-x-small; + margin-bottom: $space-x-large; + + label.is-disabled { + opacity: 0.5; + } + } + > .ds-form-item { margin: 0; } @@ -427,7 +475,7 @@ export default { cursor: default; } - > div:not(.buttons) { + > div:not(.buttons):not(.show-members-control) { display: flex; flex-direction: column; diff --git a/webapp/components/GroupTeaser/GroupTeaser.vue b/webapp/components/GroupTeaser/GroupTeaser.vue new file mode 100644 index 0000000000..1a2d7ac12e --- /dev/null +++ b/webapp/components/GroupTeaser/GroupTeaser.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/webapp/components/GroupTeaser/GroupTeaserPopover.vue b/webapp/components/GroupTeaser/GroupTeaserPopover.vue new file mode 100644 index 0000000000..3321a620dc --- /dev/null +++ b/webapp/components/GroupTeaser/GroupTeaserPopover.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/webapp/components/OcelotInput/OcelotInput.vue b/webapp/components/OcelotInput/OcelotInput.vue index f1ed6e9019..31cda17483 100644 --- a/webapp/components/OcelotInput/OcelotInput.vue +++ b/webapp/components/OcelotInput/OcelotInput.vue @@ -7,10 +7,13 @@
    +
    + {{ prefix }} +
  • +
  • + + + + group.contentMenu.hideFromProfile + + +
  • @@ -606,6 +632,14 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close +

    + + group.membersListNotVisibleToNonMembers + +

    + @@ -2718,6 +2778,14 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close +

    + + group.membersListNotVisibleToNonMembers + +

    +