diff --git a/backend/src/middleware/permissionsMiddleware.js b/backend/src/middleware/permissionsMiddleware.js index a0116a439..d312bc112 100644 --- a/backend/src/middleware/permissionsMiddleware.js +++ b/backend/src/middleware/permissionsMiddleware.js @@ -135,6 +135,7 @@ const permissions = shield( blockedUsers: isAuthenticated, notifications: isAuthenticated, profilePagePosts: or(onlyEnabledContent, isModerator), + Donations: isAuthenticated, }, Mutation: { '*': deny, @@ -177,6 +178,7 @@ const permissions = shield( VerifyEmailAddress: isAuthenticated, pinPost: isAdmin, unpinPost: isAdmin, + UpdateDonations: isAdmin, }, User: { email: or(isMyOwn, isAdmin), diff --git a/backend/src/models/Donations.js b/backend/src/models/Donations.js new file mode 100644 index 000000000..45e06e1d4 --- /dev/null +++ b/backend/src/models/Donations.js @@ -0,0 +1,14 @@ +import uuid from 'uuid/v4' + +module.exports = { + id: { type: 'string', primary: true, default: uuid }, + goal: { type: 'number' }, + progress: { type: 'number' }, + createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, + updatedAt: { + type: 'string', + isoDate: true, + required: true, + default: () => new Date().toISOString(), + }, +} diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 08362b69f..bd89ddc51 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -12,4 +12,5 @@ export default { Category: require('./Category.js'), Tag: require('./Tag.js'), Location: require('./Location.js'), + Donations: require('./Donations.js'), } diff --git a/backend/src/schema/index.js b/backend/src/schema/index.js index 95fa9ef61..b1bd36451 100644 --- a/backend/src/schema/index.js +++ b/backend/src/schema/index.js @@ -24,6 +24,7 @@ export default applyScalars( 'SocialMedia', 'NOTIFIED', 'REPORTED', + 'Donations', ], // add 'User' here as soon as possible }, @@ -44,6 +45,7 @@ export default applyScalars( 'EMOTED', 'NOTIFIED', 'REPORTED', + 'Donations', ], // add 'User' here as soon as possible }, diff --git a/backend/src/schema/resolvers/donations.js b/backend/src/schema/resolvers/donations.js new file mode 100644 index 000000000..88149077d --- /dev/null +++ b/backend/src/schema/resolvers/donations.js @@ -0,0 +1,32 @@ +export default { + Mutation: { + UpdateDonations: async (_parent, params, context, _resolveInfo) => { + const { driver } = context + const session = driver.session() + let donations + const writeTxResultPromise = session.writeTransaction(async txc => { + const updateDonationsTransactionResponse = await txc.run( + ` + MATCH (donations:Donations) + WITH donations LIMIT 1 + SET donations += $params + SET donations.updatedAt = toString(datetime()) + RETURN donations + `, + { params }, + ) + return updateDonationsTransactionResponse.records.map( + record => record.get('donations').properties, + ) + }) + try { + const txResult = await writeTxResultPromise + if (!txResult[0]) return null + donations = txResult[0] + } finally { + session.close() + } + return donations + }, + }, +} diff --git a/backend/src/schema/resolvers/donations.spec.js b/backend/src/schema/resolvers/donations.spec.js new file mode 100644 index 000000000..327688d3a --- /dev/null +++ b/backend/src/schema/resolvers/donations.spec.js @@ -0,0 +1,174 @@ +import { createTestClient } from 'apollo-server-testing' +import Factory from '../../seed/factories' +import { gql } from '../../jest/helpers' +import { neode as getNeode, getDriver } from '../../bootstrap/neo4j' +import createServer from '../../server' + +let mutate, query, authenticatedUser, variables +const factory = Factory() +const instance = getNeode() +const driver = getDriver() + +const updateDonationsMutation = gql` + mutation($goal: Int, $progress: Int) { + UpdateDonations(goal: $goal, progress: $progress) { + id + goal + progress + createdAt + updatedAt + } + } +` +const donationsQuery = gql` + query { + Donations { + id + goal + progress + } + } +` + +describe('donations', () => { + let currentUser, newlyCreatedDonations + beforeAll(async () => { + await factory.cleanDatabase() + authenticatedUser = undefined + const { server } = createServer({ + context: () => { + return { + driver, + neode: instance, + user: authenticatedUser, + } + }, + }) + mutate = createTestClient(server).mutate + query = createTestClient(server).query + }) + + beforeEach(async () => { + variables = {} + newlyCreatedDonations = await factory.create('Donations') + }) + + afterEach(async () => { + await factory.cleanDatabase() + }) + + describe('query for donations', () => { + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = undefined + await expect(query({ query: donationsQuery, variables })).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + }) + }) + + describe('authenticated', () => { + beforeEach(async () => { + currentUser = await factory.create('User', { + id: 'normal-user', + role: 'user', + }) + authenticatedUser = await currentUser.toJson() + }) + + it('returns the current Donations info', async () => { + await expect(query({ query: donationsQuery, variables })).resolves.toMatchObject({ + data: { Donations: [{ goal: 15000, progress: 0 }] }, + }) + }) + }) + }) + }) + + describe('update donations', () => { + beforeEach(() => { + variables = { goal: 20000, progress: 3000 } + }) + + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = undefined + await expect( + mutate({ mutation: updateDonationsMutation, variables }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorised!' }], + }) + }) + + describe('authenticated', () => { + describe('as a normal user', () => { + beforeEach(async () => { + currentUser = await factory.create('User', { + id: 'normal-user', + role: 'user', + }) + authenticatedUser = await currentUser.toJson() + }) + + it('throws authorization error', async () => { + await expect( + mutate({ mutation: updateDonationsMutation, variables }), + ).resolves.toMatchObject({ + data: { UpdateDonations: null }, + errors: [{ message: 'Not Authorised!' }], + }) + }) + }) + + describe('as a moderator', () => { + beforeEach(async () => { + currentUser = await factory.create('User', { + id: 'moderator', + role: 'moderator', + }) + authenticatedUser = await currentUser.toJson() + }) + + it('throws authorization error', async () => { + await expect( + mutate({ mutation: updateDonationsMutation, variables }), + ).resolves.toMatchObject({ + data: { UpdateDonations: null }, + errors: [{ message: 'Not Authorised!' }], + }) + }) + }) + + describe('as an admin', () => { + beforeEach(async () => { + currentUser = await factory.create('User', { + id: 'admin', + role: 'admin', + }) + authenticatedUser = await currentUser.toJson() + }) + + it('updates Donations info', async () => { + await expect( + mutate({ mutation: updateDonationsMutation, variables }), + ).resolves.toMatchObject({ + data: { UpdateDonations: { goal: 20000, progress: 3000 } }, + errors: undefined, + }) + }) + + it('updates the updatedAt attribute', async () => { + newlyCreatedDonations = await newlyCreatedDonations.toJson() + const { + data: { UpdateDonations }, + } = await mutate({ mutation: updateDonationsMutation, variables }) + expect(newlyCreatedDonations.updatedAt).toBeTruthy() + expect(Date.parse(newlyCreatedDonations.updatedAt)).toEqual(expect.any(Number)) + expect(UpdateDonations.updatedAt).toBeTruthy() + expect(Date.parse(UpdateDonations.updatedAt)).toEqual(expect.any(Number)) + expect(newlyCreatedDonations.updatedAt).not.toEqual(UpdateDonations.updatedAt) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/schema/types/type/Donations.gql b/backend/src/schema/types/type/Donations.gql new file mode 100644 index 000000000..39cfe9b71 --- /dev/null +++ b/backend/src/schema/types/type/Donations.gql @@ -0,0 +1,15 @@ +type Donations { + id: ID! + goal: Int! + progress: Int! + createdAt: String + updatedAt: String +} + +type Query { + Donations: [Donations] +} + +type Mutation { + UpdateDonations(goal: Int, progress: Int): Donations +} \ No newline at end of file diff --git a/backend/src/seed/factories/donations.js b/backend/src/seed/factories/donations.js new file mode 100644 index 000000000..e22cdb6d7 --- /dev/null +++ b/backend/src/seed/factories/donations.js @@ -0,0 +1,18 @@ +import uuid from 'uuid/v4' + +export default function create() { + return { + factory: async ({ args, neodeInstance }) => { + const defaults = { + id: uuid(), + goal: 15000, + progress: 0, + } + args = { + ...defaults, + ...args, + } + return neodeInstance.create('Donations', args) + }, + } +} diff --git a/backend/src/seed/factories/index.js b/backend/src/seed/factories/index.js index 2c96c8698..5054155fc 100644 --- a/backend/src/seed/factories/index.js +++ b/backend/src/seed/factories/index.js @@ -8,6 +8,7 @@ import createTag from './tags.js' import createSocialMedia from './socialMedia.js' import createLocation from './locations.js' import createEmailAddress from './emailAddresses.js' +import createDonations from './donations.js' import createUnverifiedEmailAddresss from './unverifiedEmailAddresses.js' const factories = { @@ -21,6 +22,7 @@ const factories = { Location: createLocation, EmailAddress: createEmailAddress, UnverifiedEmailAddress: createUnverifiedEmailAddresss, + Donations: createDonations, } export const cleanDatabase = async (options = {}) => { diff --git a/backend/src/seed/seed-db.js b/backend/src/seed/seed-db.js index 518d75cfb..c99f348cf 100644 --- a/backend/src/seed/seed-db.js +++ b/backend/src/seed/seed-db.js @@ -929,6 +929,7 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }), ) + await factory.create('Donations') /* eslint-disable-next-line no-console */ console.log('Seeded Data...') process.exit(0) diff --git a/webapp/components/DonationInfo/DonationInfo.spec.js b/webapp/components/DonationInfo/DonationInfo.spec.js new file mode 100644 index 000000000..ca719c19a --- /dev/null +++ b/webapp/components/DonationInfo/DonationInfo.spec.js @@ -0,0 +1,80 @@ +import { mount, createLocalVue } from '@vue/test-utils' +import Styleguide from '@human-connection/styleguide' +import DonationInfo from './DonationInfo.vue' + +const localVue = createLocalVue() +localVue.use(Styleguide) + +const mockDate = new Date(2019, 11, 6) +global.Date = jest.fn(() => mockDate) + +describe('DonationInfo.vue', () => { + let mocks, wrapper + + beforeEach(() => { + mocks = { + $t: jest.fn(string => string), + $i18n: { + locale: () => 'de', + }, + } + }) + + const Wrapper = () => mount(DonationInfo, { mocks, localVue }) + + it('includes a link to the Human Connection donations website', () => { + expect( + Wrapper() + .find('a') + .attributes('href'), + ).toBe('https://human-connection.org/spenden/') + }) + + it('displays a call to action button', () => { + expect( + Wrapper() + .find('.ds-button') + .text(), + ).toBe('donations.donate-now') + }) + + it('creates a title from the current month and a translation string', () => { + mocks.$t = jest.fn(() => 'Spenden für') + expect(Wrapper().vm.title).toBe('Spenden für Dezember') + }) + + describe('mount with data', () => { + beforeEach(() => { + wrapper = Wrapper() + wrapper.setData({ goal: 50000, progress: 10000 }) + }) + + describe('given german locale', () => { + it('creates a label from the given amounts and a translation string', () => { + expect(mocks.$t).toBeCalledWith( + 'donations.amount-of-total', + expect.objectContaining({ + amount: '10.000', + total: '50.000', + }), + ) + }) + }) + + describe('given english locale', () => { + beforeEach(() => { + mocks.$i18n.locale = () => 'en' + }) + + it('creates a label from the given amounts and a translation string', () => { + expect(mocks.$t).toBeCalledWith( + 'donations.amount-of-total', + expect.objectContaining({ + amount: '10,000', + total: '50,000', + }), + ) + }) + }) + }) +}) diff --git a/webapp/components/DonationInfo/DonationInfo.vue b/webapp/components/DonationInfo/DonationInfo.vue new file mode 100644 index 000000000..10f42e880 --- /dev/null +++ b/webapp/components/DonationInfo/DonationInfo.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/webapp/components/ProgressBar/ProgressBar.spec.js b/webapp/components/ProgressBar/ProgressBar.spec.js new file mode 100644 index 000000000..6fb6f1666 --- /dev/null +++ b/webapp/components/ProgressBar/ProgressBar.spec.js @@ -0,0 +1,65 @@ +import { mount } from '@vue/test-utils' +import ProgressBar from './ProgressBar' + +describe('ProgessBar.vue', () => { + let propsData + + beforeEach(() => { + propsData = { + goal: 50000, + progress: 10000, + } + }) + + const Wrapper = () => mount(ProgressBar, { propsData }) + + describe('given only goal and progress', () => { + it('renders no title', () => { + expect( + Wrapper() + .find('.progress-bar__title') + .exists(), + ).toBe(false) + }) + + it('renders no label', () => { + expect( + Wrapper() + .find('.progress-bar__label') + .exists(), + ).toBe(false) + }) + + it('calculates the progress bar width as a percentage of the goal', () => { + expect(Wrapper().vm.progressBarWidth).toBe('width: 20%;') + }) + }) + + describe('given a title', () => { + beforeEach(() => { + propsData.title = 'This is progress' + }) + + it('renders the title', () => { + expect( + Wrapper() + .find('.progress-bar__title') + .text(), + ).toBe('This is progress') + }) + }) + + describe('given a label', () => { + beforeEach(() => { + propsData.label = 'Going well' + }) + + it('renders the label', () => { + expect( + Wrapper() + .find('.progress-bar__label') + .text(), + ).toBe('Going well') + }) + }) +}) diff --git a/webapp/components/ProgressBar/ProgressBar.vue b/webapp/components/ProgressBar/ProgressBar.vue new file mode 100644 index 000000000..9fd799b39 --- /dev/null +++ b/webapp/components/ProgressBar/ProgressBar.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/webapp/graphql/Donations.js b/webapp/graphql/Donations.js new file mode 100644 index 000000000..cc2a6a783 --- /dev/null +++ b/webapp/graphql/Donations.js @@ -0,0 +1,24 @@ +import gql from 'graphql-tag' + +export const DonationsQuery = () => gql` + query { + Donations { + id + goal + progress + } + } +` + +export const UpdateDonations = () => { + return gql` + mutation($goal: Int, $progress: Int) { + UpdateDonations(goal: $goal, progress: $progress) { + id + goal + progress + updatedAt + } + } + ` +} diff --git a/webapp/locales/de.json b/webapp/locales/de.json index f5100ad49..a9cf7ce2c 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -62,6 +62,11 @@ } } }, + "donations": { + "donations-for": "Spenden für", + "donate-now": "Jetzt spenden", + "amount-of-total": "{amount} von {total} € erreicht" + }, "maintenance": { "title": "Human Connection befindet sich in der Wartung", "explanation": "Zurzeit führen wir einige geplante Wartungsarbeiten durch, bitte versuch es später erneut.", @@ -375,6 +380,12 @@ "name": "Benutzer einladen", "title": "Leute einladen", "description": "Einladungen sind ein wunderbarer Weg, deine Freund in deinem Netzwerk zu haben …" + }, + "donations": { + "name": "Spendeninfo", + "goal": "Monatlich benötigte Spenden", + "progress": "Bereits gesammelte Spenden", + "successfulUpdate": "Spenden-Info erfolgreich aktualisiert!" } }, "post": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index e591b3b68..15ef63a95 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -63,6 +63,11 @@ } } }, + "donations": { + "donations-for": "Donations for", + "donate-now": "Donate now", + "amount-of-total": "{amount} of {total} € collected" + }, "maintenance": { "title": "Human Connection is under maintenance", "explanation": "At the moment we are doing some scheduled maintenance, please try again later.", @@ -376,6 +381,12 @@ "name": "Invite users", "title": "Invite people", "description": "Invitations are a wonderful way to have your friends in your network …" + }, + "donations": { + "name": "Donations info", + "goal": "Monthly donations needed", + "progress": "Donations collected so far", + "successfulUpdate": "Donations info updated successfully!" } }, "post": { diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json index 900b65f67..76560aba9 100644 --- a/webapp/locales/pt.json +++ b/webapp/locales/pt.json @@ -380,6 +380,12 @@ "name": "Convidar usuários", "title": "Convidar pessoas", "description": "Convites são uma maneira maravilhosa de ter seus amigos em sua rede …" + }, + "donations": { + "name": "Informações sobre Doações", + "goal": "Doações mensais necessárias", + "progress": "Doações arrecadadas até o momento", + "successfulUpdate": "Informações sobre doações atualizadas com sucesso!" } }, "post": { diff --git a/webapp/pages/admin.vue b/webapp/pages/admin.vue index 7852673b0..8f2b989d6 100644 --- a/webapp/pages/admin.vue +++ b/webapp/pages/admin.vue @@ -55,6 +55,10 @@ export default { name: this.$t('admin.invites.name'), path: `/admin/invite`, }, + { + name: this.$t('admin.donations.name'), + path: '/admin/donations', + }, // TODO implement /* { name: this.$t('admin.settings.name'), diff --git a/webapp/pages/admin/donations.vue b/webapp/pages/admin/donations.vue new file mode 100644 index 000000000..7f0205be5 --- /dev/null +++ b/webapp/pages/admin/donations.vue @@ -0,0 +1,63 @@ + + + diff --git a/webapp/pages/index.spec.js b/webapp/pages/index.spec.js index d8587aaf4..0cee06b38 100644 --- a/webapp/pages/index.spec.js +++ b/webapp/pages/index.spec.js @@ -64,6 +64,9 @@ describe('PostIndex', () => { truncate: a => a, removeLinks: jest.fn(), }, + $i18n: { + locale: () => 'de', + }, // If you are mocking router, than don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html $router: { history: { diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index f15634ccc..caf5d54f6 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -4,7 +4,8 @@ - + +