diff --git a/backend/package.json b/backend/package.json index f6b66b07e..786fe6641 100644 --- a/backend/package.json +++ b/backend/package.json @@ -69,6 +69,7 @@ "helmet": "~3.22.0", "ioredis": "^4.16.1", "jsonwebtoken": "~8.5.1", + "languagedetect": "^2.0.0", "linkifyjs": "~2.1.8", "lodash": "~4.17.14", "merge-graphql-schemas": "^1.7.7", diff --git a/backend/src/db/factories.js b/backend/src/db/factories.js index 1ebf063ff..7ea82f661 100644 --- a/backend/src/db/factories.js +++ b/backend/src/db/factories.js @@ -5,6 +5,7 @@ import { hashSync } from 'bcryptjs' import { Factory } from 'rosie' import { getDriver, getNeode } from './neo4j' import CONFIG from '../config/index.js' +import generateInviteCode from '../schema/resolvers/helpers/generateInviteCode.js' const neode = getNeode() @@ -205,7 +206,7 @@ const emailDefaults = { } Factory.define('emailAddress') - .attr(emailDefaults) + .attrs(emailDefaults) .after((buildObject, options) => { return neode.create('EmailAddress', buildObject) }) @@ -216,6 +217,28 @@ Factory.define('unverifiedEmailAddress') return neode.create('UnverifiedEmailAddress', buildObject) }) +const inviteCodeDefaults = { + code: () => generateInviteCode(), + createdAt: () => new Date().toISOString(), + expiresAt: () => null, +} + +Factory.define('inviteCode') + .attrs(inviteCodeDefaults) + .option('generatedById', null) + .option('generatedBy', ['generatedById'], (generatedById) => { + if (generatedById) return neode.find('User', generatedById) + return Factory.build('user') + }) + .after(async (buildObject, options) => { + const [inviteCode, generatedBy] = await Promise.all([ + neode.create('InviteCode', buildObject), + options.generatedBy, + ]) + await Promise.all([inviteCode.relateTo(generatedBy, 'generated')]) + return inviteCode + }) + Factory.define('location') .attrs({ name: 'Germany', diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index 685b5ef0e..d7bd5c73b 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -541,6 +541,16 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] ), ]) + await Factory.build( + 'inviteCode', + { + code: 'AAAAAA', + }, + { + generatedBy: jennyRostock, + }, + ) + authenticatedUser = await louie.toJson() const mention1 = 'Hey @jenny-rostock, what\'s up?' diff --git a/backend/src/middleware/index.js b/backend/src/middleware/index.js index 83b0104ec..0ad8cb1ae 100644 --- a/backend/src/middleware/index.js +++ b/backend/src/middleware/index.js @@ -14,6 +14,7 @@ import notifications from './notifications/notificationsMiddleware' import hashtags from './hashtags/hashtagsMiddleware' import email from './email/emailMiddleware' import sentry from './sentryMiddleware' +import languages from './languages/languages' export default (schema) => { const middlewares = { @@ -30,6 +31,7 @@ export default (schema) => { softDelete, includedFields, orderBy, + languages, } let order = [ @@ -39,6 +41,7 @@ export default (schema) => { // 'activityPub', disabled temporarily 'validation', 'sluggify', + 'languages', 'excerpt', 'email', 'notifications', diff --git a/backend/src/middleware/languages/languages.js b/backend/src/middleware/languages/languages.js new file mode 100644 index 000000000..3cf760f31 --- /dev/null +++ b/backend/src/middleware/languages/languages.js @@ -0,0 +1,28 @@ +import LanguageDetect from 'languagedetect' +import sanitizeHtml from 'sanitize-html' + +const removeHtmlTags = (input) => { + return sanitizeHtml(input, { + allowedTags: [], + allowedAttributes: {}, + }) +} + +const setPostLanguage = (text) => { + const lngDetector = new LanguageDetect() + lngDetector.setLanguageType('iso2') + return lngDetector.detect(removeHtmlTags(text), 1)[0][0] +} + +export default { + Mutation: { + CreatePost: async (resolve, root, args, context, info) => { + args.language = await setPostLanguage(args.content) + return resolve(root, args, context, info) + }, + UpdatePost: async (resolve, root, args, context, info) => { + args.language = await setPostLanguage(args.content) + return resolve(root, args, context, info) + }, + }, +} diff --git a/backend/src/middleware/languages/languages.spec.js b/backend/src/middleware/languages/languages.spec.js new file mode 100644 index 000000000..1448e4e4b --- /dev/null +++ b/backend/src/middleware/languages/languages.spec.js @@ -0,0 +1,132 @@ +import Factory, { cleanDatabase } from '../../db/factories' +import { gql } from '../../helpers/jest' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' +import { createTestClient } from 'apollo-server-testing' + +let mutate +let authenticatedUser +let variables + +const driver = getDriver() +const neode = getNeode() + +beforeAll(async () => { + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + mutate = createTestClient(server).mutate +}) + +afterAll(async () => { + await cleanDatabase() +}) + +const createPostMutation = gql` + mutation($title: String!, $content: String!, $categoryIds: [ID]) { + CreatePost(title: $title, content: $content, categoryIds: $categoryIds) { + language + } + } +` + +describe('languagesMiddleware', () => { + variables = { + title: 'Test post languages', + categoryIds: ['cat9'], + } + + beforeAll(async () => { + await cleanDatabase() + const user = await Factory.build('user') + authenticatedUser = await user.toJson() + await Factory.build('category', { + id: 'cat9', + name: 'Democracy & Politics', + icon: 'university', + }) + }) + + it('detects German', async () => { + variables = { + ...variables, + content: 'Jeder sollte vor seiner eigenen Tür kehren.', + } + await expect( + mutate({ + mutation: createPostMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + language: 'de', + }, + }, + }) + }) + + it('detects English', async () => { + variables = { + ...variables, + content: 'A journey of a thousand miles begins with a single step.', + } + await expect( + mutate({ + mutation: createPostMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + language: 'en', + }, + }, + }) + }) + + it('detects Spanish', async () => { + variables = { + ...variables, + content: 'A caballo regalado, no le mires el diente.', + } + await expect( + mutate({ + mutation: createPostMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + language: 'es', + }, + }, + }) + }) + + it('detects German in between lots of html tags', async () => { + variables = { + ...variables, + content: + 'Jeder sollte vor seiner eigenen
Tür
kehren.', + } + await expect( + mutate({ + mutation: createPostMutation, + variables, + }), + ).resolves.toMatchObject({ + data: { + CreatePost: { + language: 'de', + }, + }, + }) + }) +}) diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index f4f8c654b..ddf12598b 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -106,6 +106,8 @@ export default shield( notifications: isAuthenticated, Donations: isAuthenticated, userData: isAuthenticated, + MyInviteCodes: isAuthenticated, + isValidInviteCode: allow, }, Mutation: { '*': deny, @@ -149,6 +151,7 @@ export default shield( pinPost: isAdmin, unpinPost: isAdmin, UpdateDonations: isAdmin, + GenerateInviteCode: isAuthenticated, }, User: { email: or(isMyOwn, isAdmin), diff --git a/backend/src/middleware/slugify/uniqueSlug.js b/backend/src/middleware/slugify/uniqueSlug.js index ca37cd562..7cfb89c19 100644 --- a/backend/src/middleware/slugify/uniqueSlug.js +++ b/backend/src/middleware/slugify/uniqueSlug.js @@ -2,6 +2,7 @@ import slugify from 'slug' export default async function uniqueSlug(string, isUnique) { const slug = slugify(string || 'anonymous', { lower: true, + multicharmap: { Ä: 'AE', ä: 'ae', Ö: 'OE', ö: 'oe', Ü: 'UE', ü: 'ue', ß: 'ss' }, }) if (await isUnique(slug)) return slug diff --git a/backend/src/middleware/slugify/uniqueSlug.spec.js b/backend/src/middleware/slugify/uniqueSlug.spec.js index ff14a56ef..d002eae03 100644 --- a/backend/src/middleware/slugify/uniqueSlug.spec.js +++ b/backend/src/middleware/slugify/uniqueSlug.spec.js @@ -18,4 +18,16 @@ describe('uniqueSlug', () => { const isUnique = jest.fn().mockResolvedValue(true) expect(uniqueSlug(string, isUnique)).resolves.toEqual('anonymous') }) + + it('Converts umlaut to a two letter equivalent', async () => { + const umlaut = 'ÄÖÜäöüß' + const isUnique = jest.fn().mockResolvedValue(true) + await expect(uniqueSlug(umlaut, isUnique)).resolves.toEqual('aeoeueaeoeuess') + }) + + it('Removes Spanish enya and diacritics', async () => { + const diacritics = 'áàéèíìóòúùñçÁÀÉÈÍÌÓÒÚÙÑÇ' + const isUnique = jest.fn().mockResolvedValue(true) + await expect(uniqueSlug(diacritics, isUnique)).resolves.toEqual('aaeeiioouuncaaeeiioouunc') + }) }) diff --git a/backend/src/middleware/validation/validationMiddleware.js b/backend/src/middleware/validation/validationMiddleware.js index d36e64846..20933a777 100644 --- a/backend/src/middleware/validation/validationMiddleware.js +++ b/backend/src/middleware/validation/validationMiddleware.js @@ -2,8 +2,6 @@ import { UserInputError } from 'apollo-server' const COMMENT_MIN_LENGTH = 1 const NO_POST_ERR_MESSAGE = 'Comment cannot be created without a post!' -const NO_CATEGORIES_ERR_MESSAGE = - 'You cannot save a post without at least one category or more than three' const USERNAME_MIN_LENGTH = 3 const validateCreateComment = async (resolve, root, args, context, info) => { const content = args.content.replace(/<(?:.|\n)*?>/gm, '').trim() @@ -46,20 +44,6 @@ const validateUpdateComment = async (resolve, root, args, context, info) => { return resolve(root, args, context, info) } -const validatePost = async (resolve, root, args, context, info) => { - const { categoryIds } = args - if (!Array.isArray(categoryIds) || !categoryIds.length || categoryIds.length > 3) { - throw new UserInputError(NO_CATEGORIES_ERR_MESSAGE) - } - return resolve(root, args, context, info) -} - -const validateUpdatePost = async (resolve, root, args, context, info) => { - const { categoryIds } = args - if (typeof categoryIds === 'undefined') return resolve(root, args, context, info) - return validatePost(resolve, root, args, context, info) -} - const validateReport = async (resolve, root, args, context, info) => { const { resourceId } = args const { user } = context @@ -138,8 +122,6 @@ export default { Mutation: { CreateComment: validateCreateComment, UpdateComment: validateUpdateComment, - CreatePost: validatePost, - UpdatePost: validateUpdatePost, UpdateUser: validateUpdateUser, fileReport: validateReport, review: validateReview, diff --git a/backend/src/middleware/validation/validationMiddleware.spec.js b/backend/src/middleware/validation/validationMiddleware.spec.js index 74a343eeb..c3d518256 100644 --- a/backend/src/middleware/validation/validationMiddleware.spec.js +++ b/backend/src/middleware/validation/validationMiddleware.spec.js @@ -30,27 +30,7 @@ const updateCommentMutation = gql` } } ` -const createPostMutation = gql` - mutation($id: ID, $title: String!, $content: String!, $language: String, $categoryIds: [ID]) { - CreatePost( - id: $id - title: $title - content: $content - language: $language - categoryIds: $categoryIds - ) { - id - } - } -` -const updatePostMutation = gql` - mutation($id: ID!, $title: String!, $content: String!, $categoryIds: [ID]) { - UpdatePost(id: $id, title: $title, content: $content, categoryIds: $categoryIds) { - id - } - } -` const reportMutation = gql` mutation($resourceId: ID!, $reasonCategory: ReasonCategory!, $reasonDescription: String!) { fileReport( @@ -227,104 +207,6 @@ describe('validateCreateComment', () => { }) }) }) - - describe('validatePost', () => { - let createPostVariables - beforeEach(async () => { - createPostVariables = { - title: 'I am a title', - content: 'Some content', - } - authenticatedUser = await commentingUser.toJson() - }) - - describe('categories', () => { - describe('null', () => { - it('throws UserInputError', async () => { - createPostVariables = { ...createPostVariables, categoryIds: null } - await expect( - mutate({ mutation: createPostMutation, variables: createPostVariables }), - ).resolves.toMatchObject({ - data: { CreatePost: null }, - errors: [ - { - message: 'You cannot save a post without at least one category or more than three', - }, - ], - }) - }) - }) - - describe('empty', () => { - it('throws UserInputError', async () => { - createPostVariables = { ...createPostVariables, categoryIds: [] } - await expect( - mutate({ mutation: createPostMutation, variables: createPostVariables }), - ).resolves.toMatchObject({ - data: { CreatePost: null }, - errors: [ - { - message: 'You cannot save a post without at least one category or more than three', - }, - ], - }) - }) - }) - - describe('more than 3 categoryIds', () => { - it('throws UserInputError', async () => { - createPostVariables = { - ...createPostVariables, - categoryIds: ['cat9', 'cat27', 'cat15', 'cat4'], - } - await expect( - mutate({ mutation: createPostMutation, variables: createPostVariables }), - ).resolves.toMatchObject({ - data: { CreatePost: null }, - errors: [ - { - message: 'You cannot save a post without at least one category or more than three', - }, - ], - }) - }) - }) - }) - }) - - describe('validateUpdatePost', () => { - describe('post created without categories somehow', () => { - let owner, updatePostVariables - beforeEach(async () => { - const postSomehowCreated = await neode.create('Post', { - id: 'how-was-this-created', - }) - owner = await neode.create('User', { - id: 'author-of-post-without-category', - slug: 'hacker', - }) - await postSomehowCreated.relateTo(owner, 'author') - authenticatedUser = await owner.toJson() - updatePostVariables = { - id: 'how-was-this-created', - title: 'I am a title', - content: 'Some content', - categoryIds: [], - } - }) - - it('requires at least one category for successful update', async () => { - await expect( - mutate({ mutation: updatePostMutation, variables: updatePostVariables }), - ).resolves.toMatchObject({ - data: { UpdatePost: null }, - errors: [ - { message: 'You cannot save a post without at least one category or more than three' }, - ], - }) - }) - }) - }) }) describe('validateReport', () => { diff --git a/backend/src/models/InvitationCode.js b/backend/src/models/InviteCode.js similarity index 54% rename from backend/src/models/InvitationCode.js rename to backend/src/models/InviteCode.js index 138289faf..7204f1b38 100644 --- a/backend/src/models/InvitationCode.js +++ b/backend/src/models/InviteCode.js @@ -1,16 +1,17 @@ export default { + code: { type: 'string', primary: true }, createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, - token: { type: 'string', primary: true, token: true }, - generatedBy: { + expiresAt: { type: 'string', isoDate: true, default: null }, + generated: { type: 'relationship', relationship: 'GENERATED', target: 'User', direction: 'in', }, - activated: { + redeemed: { type: 'relationship', - relationship: 'ACTIVATED', - target: 'EmailAddress', - direction: 'out', + relationship: 'REDEEMED', + target: 'User', + direction: 'in', }, } diff --git a/backend/src/models/User.js b/backend/src/models/User.js index ae7e1ae8c..6cfd22268 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -100,6 +100,18 @@ export default { target: 'User', direction: 'in', }, + inviteCodes: { + type: 'relationship', + relationship: 'GENERATED', + target: 'InviteCode', + direction: 'out', + }, + redeemedInviteCode: { + type: 'relationship', + relationship: 'REDEEMED', + target: 'InviteCode', + direction: 'out', + }, termsAndConditionsAgreedVersion: { type: 'string', allow: [null], diff --git a/backend/src/models/index.js b/backend/src/models/index.js index c53ef89ab..8d6a021ab 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -15,4 +15,5 @@ export default { Donations: require('./Donations.js').default, Report: require('./Report.js').default, Migration: require('./Migration.js').default, + InviteCode: require('./InviteCode.js').default, } diff --git a/backend/src/schema/resolvers/helpers/generateInviteCode.js b/backend/src/schema/resolvers/helpers/generateInviteCode.js new file mode 100644 index 000000000..70e122d26 --- /dev/null +++ b/backend/src/schema/resolvers/helpers/generateInviteCode.js @@ -0,0 +1,8 @@ +export default function generateInviteCode() { + // 6 random numbers in [ 0, 35 ] are 36 possible numbers (10 [0-9] + 26 [A-Z]) + return Array.from({ length: 6 }, (n = Math.floor(Math.random() * 36)) => { + // n > 9: it is a letter (ASCII 65 is A) -> 10 + 55 = 65 + // else: it is a number (ASCII 48 is 0) -> 0 + 48 = 48 + return String.fromCharCode(n > 9 ? n + 55 : n + 48) + }).join('') +} diff --git a/backend/src/schema/resolvers/inviteCodes.js b/backend/src/schema/resolvers/inviteCodes.js new file mode 100644 index 000000000..91148a08d --- /dev/null +++ b/backend/src/schema/resolvers/inviteCodes.js @@ -0,0 +1,109 @@ +import generateInviteCode from './helpers/generateInviteCode' +import Resolver from './helpers/Resolver' + +const uniqueInviteCode = async (session, code) => { + return session.readTransaction(async (txc) => { + const result = await txc.run(`MATCH (ic:InviteCode { id: $code }) RETURN count(ic) AS count`, { + code, + }) + return parseInt(String(result.records[0].get('count'))) === 0 + }) +} + +export default { + Query: { + MyInviteCodes: async (_parent, args, context, _resolveInfo) => { + const { + user: { id: userId }, + } = context + const session = context.driver.session() + const readTxResultPromise = session.readTransaction(async (txc) => { + const result = await txc.run( + `MATCH (user:User {id: $userId})-[:GENERATED]->(ic:InviteCode) + RETURN properties(ic) AS inviteCodes`, + { + userId, + }, + ) + return result.records.map((record) => record.get('inviteCodes')) + }) + try { + const txResult = await readTxResultPromise + return txResult + } finally { + session.close() + } + }, + isValidInviteCode: async (_parent, args, context, _resolveInfo) => { + const { code } = args + if (!code) return false + const session = context.driver.session() + const readTxResultPromise = session.readTransaction(async (txc) => { + const result = await txc.run( + `MATCH (ic:InviteCode { code: toUpper($code) }) + RETURN + CASE + WHEN ic.expiresAt IS NULL THEN true + WHEN datetime(ic.expiresAt) >= datetime() THEN true + ELSE false END AS result`, + { + code, + }, + ) + return result.records.map((record) => record.get('result')) + }) + try { + const txResult = await readTxResultPromise + return !!txResult[0] + } finally { + session.close() + } + }, + }, + Mutation: { + GenerateInviteCode: async (_parent, args, context, _resolveInfo) => { + const { + user: { id: userId }, + } = context + const session = context.driver.session() + let code = generateInviteCode() + while (!(await uniqueInviteCode(session, code))) { + code = generateInviteCode() + } + const writeTxResultPromise = session.writeTransaction(async (txc) => { + const result = await txc.run( + `MATCH (user:User {id: $userId}) + MERGE (user)-[:GENERATED]->(ic:InviteCode { code: $code }) + ON CREATE SET + ic.createdAt = toString(datetime()), + ic.expiresAt = $expiresAt + RETURN ic AS inviteCode`, + { + userId, + code, + expiresAt: args.expiresAt, + }, + ) + return result.records.map((record) => record.get('inviteCode').properties) + }) + try { + const txResult = await writeTxResultPromise + return txResult[0] + } finally { + session.close() + } + }, + }, + InviteCode: { + ...Resolver('InviteCode', { + idAttribute: 'code', + undefinedToNull: ['expiresAt'], + hasOne: { + generatedBy: '<-[:GENERATED]-(related:User)', + }, + hasMany: { + redeemedBy: '<-[:REDEEMED]-(related:User)', + }, + }), + }, +} diff --git a/backend/src/schema/resolvers/inviteCodes.spec.js b/backend/src/schema/resolvers/inviteCodes.spec.js new file mode 100644 index 000000000..19f021437 --- /dev/null +++ b/backend/src/schema/resolvers/inviteCodes.spec.js @@ -0,0 +1,200 @@ +import Factory, { cleanDatabase } from '../../db/factories' +import { getDriver } from '../../db/neo4j' +import { gql } from '../../helpers/jest' +import createServer from '../../server' +import { createTestClient } from 'apollo-server-testing' + +let user +let query +let mutate + +const driver = getDriver() + +const generateInviteCodeMutation = gql` + mutation($expiresAt: String = null) { + GenerateInviteCode(expiresAt: $expiresAt) { + code + createdAt + expiresAt + } + } +` + +const myInviteCodesQuery = gql` + query { + MyInviteCodes { + code + createdAt + expiresAt + } + } +` + +const isValidInviteCodeQuery = gql` + query($code: ID!) { + isValidInviteCode(code: $code) + } +` + +beforeAll(async () => { + await cleanDatabase() + const { server } = createServer({ + context: () => { + return { + driver, + user, + } + }, + }) + query = createTestClient(server).query + mutate = createTestClient(server).mutate +}) + +afterAll(async () => { + await cleanDatabase() +}) + +describe('inviteCodes', () => { + describe('as unauthenticated user', () => { + it('cannot generate invite codes', async () => { + await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual( + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + }), + ]), + data: { + GenerateInviteCode: null, + }, + }), + ) + }) + + it('cannot query invite codes', async () => { + await expect(query({ query: myInviteCodesQuery })).resolves.toEqual( + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + }), + ]), + data: { + MyInviteCodes: null, + }, + }), + ) + }) + }) + + describe('as authenticated user', () => { + beforeAll(async () => { + const authenticatedUser = await Factory.build( + 'user', + { + role: 'user', + }, + { + email: 'user@example.org', + password: '1234', + }, + ) + user = await authenticatedUser.toJson() + }) + + it('generates an invite code without expiresAt', async () => { + await expect(mutate({ mutation: generateInviteCodeMutation })).resolves.toEqual( + expect.objectContaining({ + errors: undefined, + data: { + GenerateInviteCode: { + code: expect.stringMatching(/^[0-9A-Z]{6,6}$/), + expiresAt: null, + createdAt: expect.any(String), + }, + }, + }), + ) + }) + + it('generates an invite code with expiresAt', async () => { + const nextWeek = new Date() + nextWeek.setDate(nextWeek.getDate() + 7) + await expect( + mutate({ + mutation: generateInviteCodeMutation, + variables: { expiresAt: nextWeek.toISOString() }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: undefined, + data: { + GenerateInviteCode: { + code: expect.stringMatching(/^[0-9A-Z]{6,6}$/), + expiresAt: nextWeek.toISOString(), + createdAt: expect.any(String), + }, + }, + }), + ) + }) + + let inviteCodes + + it('returns the created invite codes when queried', async () => { + const response = await query({ query: myInviteCodesQuery }) + inviteCodes = response.data.MyInviteCodes + expect(inviteCodes).toHaveLength(2) + }) + + it('does not return the created invite codes of other users when queried', async () => { + await Factory.build('inviteCode') + const response = await query({ query: myInviteCodesQuery }) + inviteCodes = response.data.MyInviteCodes + expect(inviteCodes).toHaveLength(2) + }) + + it('validates an invite code without expiresAt', async () => { + const unExpiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt === null)[0].code + const result = await query({ + query: isValidInviteCodeQuery, + variables: { code: unExpiringInviteCode }, + }) + expect(result.data.isValidInviteCode).toBeTruthy() + }) + + it('validates an invite code in lower case', async () => { + const unExpiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt === null)[0].code + const result = await query({ + query: isValidInviteCodeQuery, + variables: { code: unExpiringInviteCode.toLowerCase() }, + }) + expect(result.data.isValidInviteCode).toBeTruthy() + }) + + it('validates an invite code with expiresAt in the future', async () => { + const expiringInviteCode = inviteCodes.filter((ic) => ic.expiresAt !== null)[0].code + const result = await query({ + query: isValidInviteCodeQuery, + variables: { code: expiringInviteCode }, + }) + expect(result.data.isValidInviteCode).toBeTruthy() + }) + + it('does not validate an invite code which expired in the past', async () => { + const lastWeek = new Date() + lastWeek.setDate(lastWeek.getDate() - 7) + const inviteCode = await Factory.build('inviteCode', { + expiresAt: lastWeek.toISOString(), + }) + const code = inviteCode.get('code') + const result = await query({ query: isValidInviteCodeQuery, variables: { code } }) + expect(result.data.isValidInviteCode).toBeFalsy() + }) + + it('does not validate an invite code which does not exits', async () => { + const result = await query({ query: isValidInviteCodeQuery, variables: { code: 'AAA' } }) + expect(result.data.isValidInviteCode).toBeFalsy() + }) + }) +}) diff --git a/backend/src/schema/resolvers/posts.js b/backend/src/schema/resolvers/posts.js index f209158fe..14e645730 100644 --- a/backend/src/schema/resolvers/posts.js +++ b/backend/src/schema/resolvers/posts.js @@ -76,7 +76,6 @@ export default { }, Mutation: { CreatePost: async (_parent, params, context, _resolveInfo) => { - const { categoryIds } = params const { image: imageInput } = params delete params.categoryIds delete params.image @@ -92,13 +91,9 @@ export default { WITH post MATCH (author:User {id: $userId}) MERGE (post)<-[:WROTE]-(author) - WITH post - UNWIND $categoryIds AS categoryId - MATCH (category:Category {id: categoryId}) - MERGE (post)-[:CATEGORIZED]->(category) RETURN post {.*} `, - { userId: context.user.id, categoryIds, params }, + { userId: context.user.id, params }, ) const [post] = createPostTransactionResponse.records.map((record) => record.get('post')) if (imageInput) { diff --git a/backend/src/schema/resolvers/posts.spec.js b/backend/src/schema/resolvers/posts.spec.js index b24383fba..f0c57b8fb 100644 --- a/backend/src/schema/resolvers/posts.spec.js +++ b/backend/src/schema/resolvers/posts.spec.js @@ -317,19 +317,6 @@ describe('CreatePost', () => { expected, ) }) - - describe('language', () => { - beforeEach(() => { - variables = { ...variables, language: 'es' } - }) - - it('allows a user to set the language of the post', async () => { - const expected = { data: { CreatePost: { language: 'es' } } } - await expect(mutate({ mutation: createPostMutation, variables })).resolves.toMatchObject( - expected, - ) - }) - }) }) }) diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index e276968e5..edc81482f 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -293,6 +293,7 @@ export default { avatar: '-[:AVATAR_IMAGE]->(related:Image)', invitedBy: '<-[:INVITED]-(related:User)', location: '-[:IS_IN]->(related:Location)', + redeemedInviteCode: '-[:REDEEMED]->(related:InviteCode)', }, hasMany: { followedBy: '<-[:FOLLOWS]-(related:User)', @@ -304,6 +305,7 @@ export default { shouted: '-[:SHOUTED]->(related:Post)', categories: '-[:CATEGORIZED]->(related:Category)', badges: '<-[:REWARDED]-(related:Badge)', + inviteCodes: '-[:GENERATED]->(related:InviteCode)', }, }), }, diff --git a/backend/src/schema/types/type/InviteCode.gql b/backend/src/schema/types/type/InviteCode.gql new file mode 100644 index 000000000..8ad7851a2 --- /dev/null +++ b/backend/src/schema/types/type/InviteCode.gql @@ -0,0 +1,17 @@ +type InviteCode { + code: ID! + createdAt: String! + generatedBy: User @relation(name: "GENERATED", direction: "IN") + redeemedBy: [User] @relation(name: "REDEEMED", direction: "IN") + expiresAt: String +} + + +type Mutation { + GenerateInviteCode(expiresAt: String = null): InviteCode +} + +type Query { + MyInviteCodes: [InviteCode] + isValidInviteCode(code: ID!): Boolean +} diff --git a/backend/src/schema/types/type/Post.gql b/backend/src/schema/types/type/Post.gql index dc6a00a41..37f9dd176 100644 --- a/backend/src/schema/types/type/Post.gql +++ b/backend/src/schema/types/type/Post.gql @@ -136,7 +136,7 @@ type Post { """ ) tags: [Tag]! @relation(name: "TAGGED", direction: "OUT") - categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT") + categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") comments: [Comment]! @relation(name: "COMMENTS", direction: "IN") commentsCount: Int! diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index e6e7191c5..712eda90c 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -56,6 +56,9 @@ type User { followedBy: [User]! @relation(name: "FOLLOWS", direction: "IN") followedByCount: Int! @cypher(statement: "MATCH (this)<-[:FOLLOWS]-(r:User) RETURN COUNT(DISTINCT r)") + inviteCodes: [InviteCode] @relation(name: "GENERATED", direction: "OUT") + redeemedInviteCode: InviteCode @relation(name: "REDEEMED", direction: "OUT") + # Is the currently logged in user following that user? followedByCurrentUser: Boolean! @cypher( statement: """ @@ -83,7 +86,7 @@ type User { RETURN COUNT(user) >= 1 """ ) - + # contributions: [WrittenPost]! # contributions2(first: Int = 10, offset: Int = 0): [WrittenPost2]! # @cypher( @@ -104,7 +107,7 @@ type User { 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)") - categories: [Category]! @relation(name: "CATEGORIZED", direction: "OUT") + categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") badges: [Badge]! @relation(name: "REWARDED", direction: "IN") badgesCount: Int! @cypher(statement: "MATCH (this)<-[:REWARDED]-(r:Badge) RETURN COUNT(r)") diff --git a/backend/yarn.lock b/backend/yarn.lock index 0bbc62515..7d6558da0 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -6302,6 +6302,11 @@ knuth-shuffle-seeded@^1.0.6: dependencies: seed-random "~2.2.0" +languagedetect@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/languagedetect/-/languagedetect-2.0.0.tgz#4b8fa2b7593b2a3a02fb1100891041c53238936c" + integrity sha512-AZb/liiQ+6ZoTj4f1J0aE6OkzhCo8fyH+tuSaPfSo8YHCWLFJrdSixhtO2TYdIkjcDQNaR4RmGaV2A5FJklDMQ== + latest-version@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15" diff --git a/webapp/components/ContributionForm/ContributionForm.spec.js b/webapp/components/ContributionForm/ContributionForm.spec.js index be2228845..eb4e9cd5a 100644 --- a/webapp/components/ContributionForm/ContributionForm.spec.js +++ b/webapp/components/ContributionForm/ContributionForm.spec.js @@ -1,10 +1,8 @@ import { config, mount } from '@vue/test-utils' import ContributionForm from './ContributionForm.vue' -import Vue from 'vue' import Vuex from 'vuex' import PostMutations from '~/graphql/PostMutations.js' -import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect' import ImageUploader from '~/components/ImageUploader/ImageUploader' import MutationObserver from 'mutation-observer' @@ -17,45 +15,8 @@ config.stubs['client-only'] = '' config.stubs['nuxt-link'] = '' config.stubs['v-popover'] = '' -const categories = [ - { - id: 'cat3', - slug: 'health-wellbeing', - icon: 'medkit', - }, - { - id: 'cat12', - slug: 'it-internet-data-privacy', - icon: 'mouse-pointer', - }, - { - id: 'cat9', - slug: 'democracy-politics', - icon: 'university', - }, - { - id: 'cat15', - slug: 'consumption-sustainability', - icon: 'shopping-cart', - }, - { - id: 'cat4', - slug: 'environment-nature', - icon: 'tree', - }, -] - describe('ContributionForm.vue', () => { - let wrapper, - postTitleInput, - expectedParams, - cancelBtn, - mocks, - propsData, - categoryIds, - englishLanguage, - deutschLanguage, - dataPrivacyButton + let wrapper, postTitleInput, expectedParams, cancelBtn, mocks, propsData const postTitle = 'this is a title for a post' const postTitleTooShort = 'xx' let postTitleTooLong = '' @@ -82,8 +43,6 @@ describe('ContributionForm.vue', () => { slug: 'this-is-a-title-for-a-post', content: postContent, contentExcerpt: postContent, - language: 'en', - categoryIds, }, }, }), @@ -136,18 +95,9 @@ describe('ContributionForm.vue', () => { describe('CreatePost', () => { describe('invalid form submission', () => { beforeEach(async () => { - wrapper.find(CategoriesSelect).setData({ categories }) postTitleInput = wrapper.find('.ds-input') postTitleInput.setValue(postTitle) await wrapper.vm.updateEditorContent(postContent) - englishLanguage = wrapper - .findAll('li') - .filter((language) => language.text() === 'English') - englishLanguage.trigger('click') - dataPrivacyButton = await wrapper - .find(CategoriesSelect) - .find('[data-test="category-buttons-cat12"]') - dataPrivacyButton.trigger('click') }) it('title cannot be empty', async () => { @@ -173,22 +123,6 @@ describe('ContributionForm.vue', () => { await wrapper.find('form').trigger('submit') expect(mocks.$apollo.mutate).not.toHaveBeenCalled() }) - - it('has at least one category', async () => { - dataPrivacyButton = await wrapper - .find(CategoriesSelect) - .find('[data-test="category-buttons-cat12"]') - dataPrivacyButton.trigger('click') - wrapper.find('form').trigger('submit') - expect(mocks.$apollo.mutate).not.toHaveBeenCalled() - }) - - it('has no more than three categories', async () => { - wrapper.vm.formData.categoryIds = ['cat4', 'cat9', 'cat15', 'cat27'] - await Vue.nextTick() - wrapper.find('form').trigger('submit') - expect(mocks.$apollo.mutate).not.toHaveBeenCalled() - }) }) describe('valid form submission', () => { @@ -198,43 +132,20 @@ describe('ContributionForm.vue', () => { variables: { title: postTitle, content: postContent, - language: 'en', id: null, - categoryIds: ['cat12'], image: null, }, } postTitleInput = wrapper.find('.ds-input') postTitleInput.setValue(postTitle) await wrapper.vm.updateEditorContent(postContent) - wrapper.find(CategoriesSelect).setData({ categories }) - englishLanguage = wrapper - .findAll('li') - .filter((language) => language.text() === 'English') - englishLanguage.trigger('click') - await Vue.nextTick() - dataPrivacyButton = await wrapper - .find(CategoriesSelect) - .find('[data-test="category-buttons-cat12"]') - dataPrivacyButton.trigger('click') - await Vue.nextTick() }) - it('creates a post with valid title, content, and at least one category', async () => { + it('creates a post with valid title and content', async () => { await wrapper.find('form').trigger('submit') expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) - it('supports changing the language', async () => { - expectedParams.variables.language = 'de' - deutschLanguage = wrapper - .findAll('li') - .filter((language) => language.text() === 'Deutsch') - deutschLanguage.trigger('click') - wrapper.find('form').trigger('submit') - expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) - }) - it('supports adding a teaser image', async () => { expectedParams.variables.image = { aspectRatio: null, @@ -292,18 +203,6 @@ describe('ContributionForm.vue', () => { postTitleInput = wrapper.find('.ds-input') postTitleInput.setValue(postTitle) await wrapper.vm.updateEditorContent(postContent) - categoryIds = ['cat12'] - wrapper.find(CategoriesSelect).setData({ categories }) - englishLanguage = wrapper - .findAll('li') - .filter((language) => language.text() === 'English') - englishLanguage.trigger('click') - await Vue.nextTick() - dataPrivacyButton = await wrapper - .find(CategoriesSelect) - .find('[data-test="category-buttons-cat12"]') - dataPrivacyButton.trigger('click') - await Vue.nextTick() }) it('shows an error toaster when apollo mutation rejects', async () => { @@ -322,14 +221,7 @@ describe('ContributionForm.vue', () => { slug: 'dies-ist-ein-post', title: 'dies ist ein Post', content: 'auf Deutsch geschrieben', - language: 'de', image, - categories: [ - { - id: 'cat12', - name: 'Democracy & Politics', - }, - ], }, } wrapper = Wrapper() @@ -352,8 +244,6 @@ describe('ContributionForm.vue', () => { slug: 'this-is-a-title-for-a-post', content: postContent, contentExcerpt: postContent, - language: 'en', - categoryIds, }, }, }) @@ -363,9 +253,7 @@ describe('ContributionForm.vue', () => { variables: { title: propsData.contribution.title, content: propsData.contribution.content, - language: propsData.contribution.language, id: propsData.contribution.id, - categoryIds: ['cat12'], image: { sensitive: false, }, @@ -380,18 +268,6 @@ describe('ContributionForm.vue', () => { expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) }) - it('supports updating categories', async () => { - expectedParams.variables.categoryIds.push('cat3') - wrapper.find(CategoriesSelect).setData({ categories }) - await Vue.nextTick() - const healthWellbeingButton = await wrapper - .find(CategoriesSelect) - .find('[data-test="category-buttons-cat3"]') - healthWellbeingButton.trigger('click') - await wrapper.find('form').trigger('submit') - expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expect.objectContaining(expectedParams)) - }) - it('supports deleting a teaser image', async () => { expectedParams.variables.image = null propsData.contribution.image = { url: '/uploads/someimage.png' } diff --git a/webapp/components/ContributionForm/ContributionForm.vue b/webapp/components/ContributionForm/ContributionForm.vue index 0cbd90563..42ed2799e 100644 --- a/webapp/components/ContributionForm/ContributionForm.vue +++ b/webapp/components/ContributionForm/ContributionForm.vue @@ -50,22 +50,6 @@ {{ contentLength }} - - - {{ formData.categoryIds.length }} / 3 - - - - - -
{{ $t('actions.cancel') }} @@ -81,19 +65,15 @@