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 @@
+
+
+
+
+
{{ title }}
+
{{ label }}
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ $t('actions.save') }}
+
+
+
+
+
+
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 @@
-
+
+