diff --git a/backend/README.md b/backend/README.md index e74750c46..b27ab16d9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,16 +1,18 @@ # backend ## Project setup -``` + +```bash yarn install ``` ## Seed DB -``` + +```bash yarn seed ``` -Deletes all data in database. Then seeds data in database. +Deletes all data in database. Then seeds data in database. ## Seeded Users @@ -22,3 +24,47 @@ Deletes all data in database. Then seeds data in database. | bob@baumeister.de | `Aa12345_` | `false` | `true` | `false` | | garrick@ollivander.com | | `false` | `false` | `false` | | stephen@hawking.uk | `Aa12345_` | `false` | `true` | `true` | + +## Setup GraphQL Playground + +### Setup In The Code + +Setting up the GraphQL Playground in our code requires the following steps: + +- Create an empty `.env` file in the `backend` folder and set "GRAPHIQL=true" there. +- Start or restart Docker Compose. +- For verification, Docker should display `GraphQL available at http://localhost:4000` in the terminal. +- If you open "http://localhost:4000/" in your browser, you should see the GraphQL Playground. + +### Authentication + +You need to authenticate yourself in GraphQL Playground to be able to send queries and mutations, to do so follow the steps below: + +- in Firefox go to "Network Analysis" and delete all entries +- enter and send the login query: + +```gql +{ + login(email: "bibi@bloxberg.de", password:"Aa12345_") { + id + publisherId + email + firstName + lastName + emailChecked + language + hasElopage + } +} +``` + +- search in Firefox under „Network Analysis" for the smallest size of a header and copy the value of the token +- open the header section in GraphQL Playground and set your current token by filling in and replacing `XXX`: + +```qgl +{ + "Authorization": "XXX" +} +``` + +Now you can open a new tap in the Playground and enter your query or mutation there. diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index 1d0fd2856..639326a64 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -27,6 +27,7 @@ export enum RIGHTS { GDT_BALANCE = 'GDT_BALANCE', CREATE_CONTRIBUTION = 'CREATE_CONTRIBUTION', LIST_CONTRIBUTIONS = 'LIST_CONTRIBUTIONS', + LIST_ALL_CONTRIBUTIONS = 'LIST_ALL_CONTRIBUTIONS', UPDATE_CONTRIBUTION = 'UPDATE_CONTRIBUTION', // Admin SEARCH_USERS = 'SEARCH_USERS', diff --git a/backend/src/auth/ROLES.ts b/backend/src/auth/ROLES.ts index 75e552e79..935bd94e5 100644 --- a/backend/src/auth/ROLES.ts +++ b/backend/src/auth/ROLES.ts @@ -25,6 +25,7 @@ export const ROLE_USER = new Role('user', [ RIGHTS.GDT_BALANCE, RIGHTS.CREATE_CONTRIBUTION, RIGHTS.LIST_CONTRIBUTIONS, + RIGHTS.LIST_ALL_CONTRIBUTIONS, RIGHTS.UPDATE_CONTRIBUTION, ]) export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights diff --git a/backend/src/graphql/model/Contribution.ts b/backend/src/graphql/model/Contribution.ts index dc1dd39e9..34bffd6d7 100644 --- a/backend/src/graphql/model/Contribution.ts +++ b/backend/src/graphql/model/Contribution.ts @@ -7,18 +7,24 @@ import { User } from './User' export class Contribution { constructor(contribution: dbContribution, user: User) { this.id = contribution.id - this.user = user + this.firstName = user ? user.firstName : null + this.lastName = user ? user.lastName : null this.amount = contribution.amount this.memo = contribution.memo this.createdAt = contribution.createdAt this.deletedAt = contribution.deletedAt + this.confirmedAt = contribution.confirmedAt + this.confirmedBy = contribution.confirmedBy } @Field(() => Number) id: number - @Field(() => User) - user: User + @Field(() => String, { nullable: true }) + firstName: string | null + + @Field(() => String, { nullable: true }) + lastName: string | null @Field(() => Decimal) amount: Decimal @@ -31,10 +37,21 @@ export class Contribution { @Field(() => Date, { nullable: true }) deletedAt: Date | null + + @Field(() => Date, { nullable: true }) + confirmedAt: Date | null + + @Field(() => Number, { nullable: true }) + confirmedBy: number | null } @ObjectType() export class ContributionListResult { + constructor(count: number, list: Contribution[]) { + this.linkCount = count + this.linkList = list + } + @Field(() => Int) linkCount: number diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index 7afae08f6..e6478ffc2 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -7,7 +7,7 @@ import { createContribution, updateContribution, } from '@/seeds/graphql/mutations' -import { listContributions, login } from '@/seeds/graphql/queries' +import { listAllContributions, listContributions, login } from '@/seeds/graphql/queries' import { cleanDB, resetToken, testEnvironment } from '@test/helpers' import { GraphQLError } from 'graphql' import { userFactory } from '@/seeds/factory/user' @@ -438,4 +438,82 @@ describe('ContributionResolver', () => { }) }) }) + + describe('listAllContribution', () => { + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect( + query({ + query: listAllContributions, + variables: { + currentPage: 1, + pageSize: 25, + order: 'DESC', + filterConfirmed: false, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + beforeAll(async () => { + await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, peterLustig) + const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de') + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await creationFactory(testEnv, bibisCreation!) + await query({ + query: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + await mutate({ + mutation: createContribution, + variables: { + amount: 100.0, + memo: 'Test env contribution', + creationDate: new Date().toString(), + }, + }) + }) + + it('returns allCreation', async () => { + await expect( + query({ + query: listAllContributions, + variables: { + currentPage: 1, + pageSize: 25, + order: 'DESC', + filterConfirmed: false, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + listAllContributions: { + linkCount: 2, + linkList: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + memo: 'Herzlich Willkommen bei Gradido!', + amount: '1000', + }), + expect.objectContaining({ + id: expect.any(Number), + memo: 'Test env contribution', + amount: '100', + }), + ]), + }, + }, + }), + ) + }) + }) + }) }) diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index 27f43b666..d71ecac59 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -7,7 +7,7 @@ import { FindOperator, IsNull } from '@dbTools/typeorm' import ContributionArgs from '@arg/ContributionArgs' import Paginated from '@arg/Paginated' import { Order } from '@enum/Order' -import { Contribution } from '@model/Contribution' +import { Contribution, ContributionListResult } from '@model/Contribution' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import { User } from '@model/User' import { validateContribution, getUserCreation, updateCreations } from './util/creations' @@ -58,12 +58,35 @@ export class ContributionResolver { order: { createdAt: order, }, + withDeleted: true, skip: (currentPage - 1) * pageSize, take: pageSize, }) return contributions.map((contribution) => new Contribution(contribution, new User(user))) } + @Authorized([RIGHTS.LIST_ALL_CONTRIBUTIONS]) + @Query(() => ContributionListResult) + async listAllContributions( + @Args() + { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, + ): Promise { + const [dbContributions, count] = await dbContribution.findAndCount({ + relations: ['user'], + order: { + createdAt: order, + }, + skip: (currentPage - 1) * pageSize, + take: pageSize, + }) + return new ContributionListResult( + count, + dbContributions.map( + (contribution) => new Contribution(contribution, new User(contribution.user)), + ), + ) + } + @Authorized([RIGHTS.UPDATE_CONTRIBUTION]) @Mutation(() => UnconfirmedContribution) async updateContribution( diff --git a/backend/src/seeds/graphql/queries.ts b/backend/src/seeds/graphql/queries.ts index c27ecdd66..deae5f97b 100644 --- a/backend/src/seeds/graphql/queries.ts +++ b/backend/src/seeds/graphql/queries.ts @@ -191,6 +191,24 @@ export const listContributions = gql` } } ` + +export const listAllContributions = ` +query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC) { + listAllContributions(currentPage: $currentPage, pageSize: $pageSize, order: $order) { + linkCount + linkList { + id + firstName + lastName + amount + memo + createdAt + confirmedAt + confirmedBy + } + } +} +` // from admin interface export const listUnconfirmedContributions = gql` diff --git a/backend/src/server/plugins.ts b/backend/src/server/plugins.ts index 1972bc1c8..24df45baa 100644 --- a/backend/src/server/plugins.ts +++ b/backend/src/server/plugins.ts @@ -31,20 +31,24 @@ const filterVariables = (variables: any) => { const logPlugin = { requestDidStart(requestContext: any) { const { logger } = requestContext - const { query, mutation, variables } = requestContext.request - logger.info(`Request: + const { query, mutation, variables, operationName } = requestContext.request + if (operationName !== 'IntrospectionQuery') { + logger.info(`Request: ${mutation || query}variables: ${JSON.stringify(filterVariables(variables), null, 2)}`) + } return { willSendResponse(requestContext: any) { - if (requestContext.context.user) logger.info(`User ID: ${requestContext.context.user.id}`) - if (requestContext.response.data) { - logger.info('Response Success!') - logger.trace(`Response-Data: + if (operationName !== 'IntrospectionQuery') { + if (requestContext.context.user) logger.info(`User ID: ${requestContext.context.user.id}`) + if (requestContext.response.data) { + logger.info('Response Success!') + logger.trace(`Response-Data: ${JSON.stringify(requestContext.response.data, null, 2)}`) - } - if (requestContext.response.errors) - logger.error(`Response-Errors: + } + if (requestContext.response.errors) + logger.error(`Response-Errors: ${JSON.stringify(requestContext.response.errors, null, 2)}`) + } return requestContext }, } diff --git a/database/entity/0039-contributions_table/Contribution.ts b/database/entity/0039-contributions_table/Contribution.ts index 6c7358f90..b5e6ac0e0 100644 --- a/database/entity/0039-contributions_table/Contribution.ts +++ b/database/entity/0039-contributions_table/Contribution.ts @@ -1,6 +1,15 @@ import Decimal from 'decimal.js-light' -import { BaseEntity, Column, Entity, PrimaryGeneratedColumn, DeleteDateColumn } from 'typeorm' +import { + BaseEntity, + Column, + Entity, + PrimaryGeneratedColumn, + DeleteDateColumn, + JoinColumn, + ManyToOne, +} from 'typeorm' import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer' +import { User } from '../User' @Entity('contributions') export class Contribution extends BaseEntity { @@ -10,6 +19,10 @@ export class Contribution extends BaseEntity { @Column({ unsigned: true, nullable: false, name: 'user_id' }) userId: number + @ManyToOne(() => User, (user) => user.contributions) + @JoinColumn({ name: 'user_id' }) + user: User + @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' }) createdAt: Date diff --git a/database/entity/0040-add_contribution_link_id_to_user/User.ts b/database/entity/0040-add_contribution_link_id_to_user/User.ts index 9bf76e5f5..56047345a 100644 --- a/database/entity/0040-add_contribution_link_id_to_user/User.ts +++ b/database/entity/0040-add_contribution_link_id_to_user/User.ts @@ -1,4 +1,13 @@ -import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, DeleteDateColumn } from 'typeorm' +import { + BaseEntity, + Entity, + PrimaryGeneratedColumn, + Column, + DeleteDateColumn, + OneToMany, + JoinColumn, +} from 'typeorm' +import { Contribution } from '../Contribution' @Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) export class User extends BaseEntity { @@ -76,4 +85,8 @@ export class User extends BaseEntity { default: null, }) passphrase: string + + @OneToMany(() => Contribution, (contribution) => contribution.user) + @JoinColumn({ name: 'user_id' }) + contributions?: Contribution[] } diff --git a/frontend/src/graphql/queries.js b/frontend/src/graphql/queries.js index adcd653a4..27e63d568 100644 --- a/frontend/src/graphql/queries.js +++ b/frontend/src/graphql/queries.js @@ -13,6 +13,7 @@ export const login = gql` hasElopage publisherId isAdmin + creation } } ` @@ -30,6 +31,7 @@ export const verifyLogin = gql` hasElopage publisherId isAdmin + creation } } ` diff --git a/frontend/src/store/store.js b/frontend/src/store/store.js index e95eec7b9..8fdbc519e 100644 --- a/frontend/src/store/store.js +++ b/frontend/src/store/store.js @@ -47,6 +47,9 @@ export const mutations = { hasElopage: (state, hasElopage) => { state.hasElopage = hasElopage }, + creation: (state, creation) => { + state.creation = creation + }, } export const actions = { @@ -60,6 +63,7 @@ export const actions = { commit('hasElopage', data.hasElopage) commit('publisherId', data.publisherId) commit('isAdmin', data.isAdmin) + commit('creation', data.creation) }, logout: ({ commit, state }) => { commit('token', null) @@ -71,6 +75,7 @@ export const actions = { commit('hasElopage', false) commit('publisherId', null) commit('isAdmin', false) + commit('creation', null) localStorage.clear() }, } @@ -96,6 +101,7 @@ try { newsletterState: null, hasElopage: false, publisherId: null, + creation: null, }, getters: {}, // Syncronous mutation of the state diff --git a/frontend/src/store/store.test.js b/frontend/src/store/store.test.js index 3f942fa35..651a3ccc5 100644 --- a/frontend/src/store/store.test.js +++ b/frontend/src/store/store.test.js @@ -30,6 +30,7 @@ const { publisherId, isAdmin, hasElopage, + creation, } = mutations const { login, logout } = actions @@ -139,6 +140,14 @@ describe('Vuex store', () => { expect(state.hasElopage).toBeTruthy() }) }) + + describe('creation', () => { + it('sets the state of creation', () => { + const state = { creation: null } + creation(state, true) + expect(state.creation).toEqual(true) + }) + }) }) describe('actions', () => { @@ -156,11 +165,12 @@ describe('Vuex store', () => { hasElopage: false, publisherId: 1234, isAdmin: true, + creation: ['1000', '1000', '1000'], } it('calls nine commits', () => { login({ commit, state }, commitedData) - expect(commit).toHaveBeenCalledTimes(8) + expect(commit).toHaveBeenCalledTimes(9) }) it('commits email', () => { @@ -202,6 +212,11 @@ describe('Vuex store', () => { login({ commit, state }, commitedData) expect(commit).toHaveBeenNthCalledWith(8, 'isAdmin', true) }) + + it('commits creation', () => { + login({ commit, state }, commitedData) + expect(commit).toHaveBeenNthCalledWith(9, 'creation', ['1000', '1000', '1000']) + }) }) describe('logout', () => { @@ -210,7 +225,7 @@ describe('Vuex store', () => { it('calls nine commits', () => { logout({ commit, state }) - expect(commit).toHaveBeenCalledTimes(8) + expect(commit).toHaveBeenCalledTimes(9) }) it('commits token', () => { @@ -253,6 +268,11 @@ describe('Vuex store', () => { expect(commit).toHaveBeenNthCalledWith(8, 'isAdmin', false) }) + it('commits creation', () => { + logout({ commit, state }) + expect(commit).toHaveBeenNthCalledWith(9, 'creation', null) + }) + // how to get this working? it.skip('calls localStorage.clear()', () => { const clearStorageMock = jest.fn()