Merge branch 'master' into 1574-Concept_to_introduce_Gradido-ID

This commit is contained in:
Ulf Gebhardt 2022-11-01 11:11:56 +01:00 committed by GitHub
commit 0f7df10a8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1260 additions and 133 deletions

View File

@ -4,8 +4,24 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [1.13.2](https://github.com/gradido/gradido/compare/1.13.1...1.13.2)
- fix: 🍰 Links In Contribution Messages Target Blank [`#2306`](https://github.com/gradido/gradido/pull/2306)
- fix: Link in Contribution Messages [`#2305`](https://github.com/gradido/gradido/pull/2305)
- Refactor: 🍰 Change the query so that we only look on the ``contributions`` table. [`#2217`](https://github.com/gradido/gradido/pull/2217)
- Refactor: Admin Resolver Events and Logging [`#2244`](https://github.com/gradido/gradido/pull/2244)
- contibution messages, links are recognised [`#2248`](https://github.com/gradido/gradido/pull/2248)
- fix: Include Deleted Email Contacts in User Search [`#2281`](https://github.com/gradido/gradido/pull/2281)
- fix: Pagination Contributions jumps to wrong Page [`#2284`](https://github.com/gradido/gradido/pull/2284)
- fix: Changed some texts in E-Mails and Frontend [`#2276`](https://github.com/gradido/gradido/pull/2276)
- Feat: 🍰 Add `deletedBy` To Contributions And Admin Can Not Delete Own User Contribution [`#2236`](https://github.com/gradido/gradido/pull/2236)
- deleted contributions are displayed to the user [`#2277`](https://github.com/gradido/gradido/pull/2277)
#### [1.13.1](https://github.com/gradido/gradido/compare/1.13.0...1.13.1) #### [1.13.1](https://github.com/gradido/gradido/compare/1.13.0...1.13.1)
> 20 October 2022
- release: Version 1.13.1 [`#2279`](https://github.com/gradido/gradido/pull/2279)
- Fix: correctly evaluate to EMAIL_TEST_MODE to false [`#2273`](https://github.com/gradido/gradido/pull/2273) - Fix: correctly evaluate to EMAIL_TEST_MODE to false [`#2273`](https://github.com/gradido/gradido/pull/2273)
- Refactor: Contribution resolver logs and events [`#2231`](https://github.com/gradido/gradido/pull/2231) - Refactor: Contribution resolver logs and events [`#2231`](https://github.com/gradido/gradido/pull/2231)

View File

@ -3,7 +3,7 @@
"description": "Administraion Interface for Gradido", "description": "Administraion Interface for Gradido",
"main": "index.js", "main": "index.js",
"author": "Moriz Wahl", "author": "Moriz Wahl",
"version": "1.13.1", "version": "1.13.2",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": false, "private": false,
"scripts": { "scripts": {

View File

@ -0,0 +1,38 @@
<template>
<div class="mt-2">
<span v-for="({ type, text }, index) in linkifiedMessage" :key="index">
<b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link>
<span v-else>{{ text }}</span>
</span>
</div>
</template>
<script>
const LINK_REGEX_PATTERN =
/(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))/i
export default {
name: 'LinkifyMessage',
props: {
message: {
type: String,
required: true,
},
},
computed: {
linkifiedMessage() {
const linkified = []
let string = this.message
let match
while ((match = string.match(LINK_REGEX_PATTERN))) {
if (match.index > 0)
linkified.push({ type: 'text', text: string.substring(0, match.index) })
linkified.push({ type: 'link', text: match[0] })
string = string.substring(match.index + match[0].length)
}
if (string.length > 0) linkified.push({ type: 'text', text: string })
return linkified
},
},
}
</script>

View File

@ -125,4 +125,68 @@ describe('ContributionMessagesListItem', () => {
}) })
}) })
}) })
describe('links in contribtion message', () => {
const propsData = {
message: {
id: 111,
message: 'Lorem ipsum?',
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const ModeratorItemWrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
let messageField
describe('message of only one link', () => {
beforeEach(() => {
propsData.message.message = 'https://gradido.net/de/'
wrapper = ModeratorItemWrapper()
messageField = wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)')
})
it('contains the link as text', () => {
expect(messageField.text()).toBe('https://gradido.net/de/')
})
it('contains a link to the given address', () => {
expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/')
})
})
describe('message with text and two links', () => {
beforeEach(() => {
propsData.message.message = `Here you find all you need to know about Gradido: https://gradido.net/de/
and here is the link to the repository: https://github.com/gradido/gradido`
wrapper = ModeratorItemWrapper()
messageField = wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)')
})
it('contains the whole text', () => {
expect(messageField.text())
.toBe(`Here you find all you need to know about Gradido: https://gradido.net/de/
and here is the link to the repository: https://github.com/gradido/gradido`)
})
it('contains the two links', () => {
expect(messageField.findAll('a').at(0).attributes('href')).toBe('https://gradido.net/de/')
expect(messageField.findAll('a').at(1).attributes('href')).toBe(
'https://github.com/gradido/gradido',
)
})
})
})
}) })

View File

@ -1,23 +1,28 @@
<template> <template>
<div class="contribution-messages-list-item"> <div class="contribution-messages-list-item">
<div v-if="message.isModerator" class="text-right is-moderator"> <div v-if="message.isModerator" class="text-right is-moderator">
<b-avatar square :text="initialLetters" variant="warning"></b-avatar> <b-avatar square variant="warning"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<small class="ml-4 text-success">{{ $t('moderator') }}</small> <small class="ml-4 text-success">{{ $t('moderator') }}</small>
<div class="mt-2">{{ message.message }}</div> <linkify-message :message="message.message"></linkify-message>
</div> </div>
<div v-else class="text-left is-not-moderator"> <div v-else class="text-left is-not-moderator">
<b-avatar :text="initialLetters" variant="info"></b-avatar> <b-avatar variant="info"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<div class="mt-2">{{ message.message }}</div> <linkify-message :message="message.message"></linkify-message>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import LinkifyMessage from '@/components/ContributionMessages/LinkifyMessage.vue'
export default { export default {
name: 'ContributionMessagesListItem', name: 'ContributionMessagesListItem',
components: {
LinkifyMessage,
},
props: { props: {
message: { message: {
type: Object, type: Object,

View File

@ -1,6 +1,6 @@
{ {
"name": "gradido-backend", "name": "gradido-backend",
"version": "1.13.1", "version": "1.13.2",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions", "description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts", "main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend", "repository": "https://github.com/gradido/gradido/backend",

View File

@ -66,6 +66,9 @@ export class EventTransactionCreation extends EventBasicTx {}
export class EventTransactionReceive extends EventBasicTxX {} export class EventTransactionReceive extends EventBasicTxX {}
export class EventTransactionReceiveRedeem extends EventBasicTxX {} export class EventTransactionReceiveRedeem extends EventBasicTxX {}
export class EventContributionCreate extends EventBasicCt {} export class EventContributionCreate extends EventBasicCt {}
export class EventAdminContributionCreate extends EventBasicCt {}
export class EventAdminContributionDelete extends EventBasicCt {}
export class EventAdminContributionUpdate extends EventBasicCt {}
export class EventUserCreateContributionMessage extends EventBasicCtMsg {} export class EventUserCreateContributionMessage extends EventBasicCtMsg {}
export class EventAdminCreateContributionMessage extends EventBasicCtMsg {} export class EventAdminCreateContributionMessage extends EventBasicCtMsg {}
export class EventContributionDelete extends EventBasicCt {} export class EventContributionDelete extends EventBasicCt {}
@ -74,6 +77,14 @@ export class EventContributionConfirm extends EventBasicCtX {}
export class EventContributionDeny extends EventBasicCtX {} export class EventContributionDeny extends EventBasicCtX {}
export class EventContributionLinkDefine extends EventBasicCt {} export class EventContributionLinkDefine extends EventBasicCt {}
export class EventContributionLinkActivateRedeem extends EventBasicCt {} export class EventContributionLinkActivateRedeem extends EventBasicCt {}
export class EventDeleteUser extends EventBasicUserId {}
export class EventUndeleteUser extends EventBasicUserId {}
export class EventChangeUserRole extends EventBasicUserId {}
export class EventAdminUpdateContribution extends EventBasicCt {}
export class EventAdminDeleteContribution extends EventBasicCt {}
export class EventCreateContributionLink extends EventBasicCt {}
export class EventDeleteContributionLink extends EventBasicCt {}
export class EventUpdateContributionLink extends EventBasicCt {}
export class Event { export class Event {
constructor() constructor()
@ -289,6 +300,27 @@ export class Event {
return this return this
} }
public setEventAdminContributionCreate(ev: EventAdminContributionCreate): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_CONTRIBUTION_CREATE
return this
}
public setEventAdminContributionDelete(ev: EventAdminContributionDelete): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_CONTRIBUTION_DELETE
return this
}
public setEventAdminContributionUpdate(ev: EventAdminContributionUpdate): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_CONTRIBUTION_UPDATE
return this
}
public setEventUserCreateContributionMessage(ev: EventUserCreateContributionMessage): Event { public setEventUserCreateContributionMessage(ev: EventUserCreateContributionMessage): Event {
this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId) this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId)
this.type = EventProtocolType.USER_CREATE_CONTRIBUTION_MESSAGE this.type = EventProtocolType.USER_CREATE_CONTRIBUTION_MESSAGE
@ -345,6 +377,62 @@ export class Event {
return this return this
} }
public setEventDeleteUser(ev: EventDeleteUser): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.DELETE_USER
return this
}
public setEventUndeleteUser(ev: EventUndeleteUser): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.UNDELETE_USER
return this
}
public setEventChangeUserRole(ev: EventChangeUserRole): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.CHANGE_USER_ROLE
return this
}
public setEventAdminUpdateContribution(ev: EventAdminUpdateContribution): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_UPDATE_CONTRIBUTION
return this
}
public setEventAdminDeleteContribution(ev: EventAdminDeleteContribution): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_DELETE_CONTRIBUTION
return this
}
public setEventCreateContributionLink(ev: EventCreateContributionLink): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CREATE_CONTRIBUTION_LINK
return this
}
public setEventDeleteContributionLink(ev: EventDeleteContributionLink): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.DELETE_CONTRIBUTION_LINK
return this
}
public setEventUpdateContributionLink(ev: EventUpdateContributionLink): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.UPDATE_CONTRIBUTION_LINK
return this
}
setByBasicUser(userId: number): Event { setByBasicUser(userId: number): Event {
this.setEventBasic() this.setEventBasic()
this.userId = userId this.userId = userId

View File

@ -33,6 +33,17 @@ export enum EventProtocolType {
CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM', CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM',
CONTRIBUTION_DELETE = 'CONTRIBUTION_DELETE', CONTRIBUTION_DELETE = 'CONTRIBUTION_DELETE',
CONTRIBUTION_UPDATE = 'CONTRIBUTION_UPDATE', CONTRIBUTION_UPDATE = 'CONTRIBUTION_UPDATE',
ADMIN_CONTRIBUTION_CREATE = 'ADMIN_CONTRIBUTION_CREATE',
ADMIN_CONTRIBUTION_DELETE = 'ADMIN_CONTRIBUTION_DELETE',
ADMIN_CONTRIBUTION_UPDATE = 'ADMIN_CONTRIBUTION_UPDATE',
USER_CREATE_CONTRIBUTION_MESSAGE = 'USER_CREATE_CONTRIBUTION_MESSAGE', USER_CREATE_CONTRIBUTION_MESSAGE = 'USER_CREATE_CONTRIBUTION_MESSAGE',
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE', ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE',
DELETE_USER = 'DELETE_USER',
UNDELETE_USER = 'UNDELETE_USER',
CHANGE_USER_ROLE = 'CHANGE_USER_ROLE',
ADMIN_UPDATE_CONTRIBUTION = 'ADMIN_UPDATE_CONTRIBUTION',
ADMIN_DELETE_CONTRIBUTION = 'ADMIN_DELETE_CONTRIBUTION',
CREATE_CONTRIBUTION_LINK = 'CREATE_CONTRIBUTION_LINK',
DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK',
UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK',
} }

View File

@ -42,6 +42,9 @@ import { Contribution } from '@entity/Contribution'
import { Transaction as DbTransaction } from '@entity/Transaction' import { Transaction as DbTransaction } from '@entity/Transaction'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail'
import { EventProtocol } from '@entity/EventProtocol'
import { EventProtocolType } from '@/event/EventProtocolType'
import { logger } from '@test/testSetup'
// mock account activation email to avoid console spam // mock account activation email to avoid console spam
jest.mock('@/mailer/sendAccountActivationEmail', () => { jest.mock('@/mailer/sendAccountActivationEmail', () => {
@ -144,6 +147,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
})
}) })
describe('change role with success', () => { describe('change role with success', () => {
@ -196,6 +203,9 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Administrator can not change his own role!')
})
}) })
describe('user has already role to be set', () => { describe('user has already role to be set', () => {
@ -213,6 +223,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is already admin!')
})
}) })
describe('to usual user', () => { describe('to usual user', () => {
@ -229,6 +243,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is already a usual user!')
})
}) })
}) })
}) })
@ -297,6 +315,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
})
}) })
describe('delete self', () => { describe('delete self', () => {
@ -309,6 +331,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Moderator can not delete his own account!')
})
}) })
describe('delete with success', () => { describe('delete with success', () => {
@ -338,6 +364,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`)
})
}) })
}) })
}) })
@ -405,6 +435,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
})
}) })
describe('user to undelete is not deleted', () => { describe('user to undelete is not deleted', () => {
@ -422,6 +456,10 @@ describe('AdminResolver', () => {
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is not deleted')
})
describe('undelete deleted user', () => { describe('undelete deleted user', () => {
beforeAll(async () => { beforeAll(async () => {
await mutate({ mutation: deleteUser, variables: { userId: user.id } }) await mutate({ mutation: deleteUser, variables: { userId: user.id } })
@ -909,6 +947,12 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'Could not find user with email: bibi@bloxberg.de',
)
})
}) })
describe('user to create for is deleted', () => { describe('user to create for is deleted', () => {
@ -928,6 +972,12 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'This user was deleted. Cannot create a contribution.',
)
})
}) })
describe('user to create for has email not confirmed', () => { describe('user to create for has email not confirmed', () => {
@ -947,6 +997,12 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'Contribution could not be saved, Email is not activated',
)
})
}) })
describe('valid user to create for', () => { describe('valid user to create for', () => {
@ -967,6 +1023,13 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
'Invalid Date',
)
})
}) })
describe('date of creation is four months ago', () => { describe('date of creation is four months ago', () => {
@ -987,6 +1050,13 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
variables.creationDate,
)
})
}) })
describe('date of creation is in the future', () => { describe('date of creation is in the future', () => {
@ -1007,6 +1077,13 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
variables.creationDate,
)
})
}) })
describe('amount of creation is too high', () => { describe('amount of creation is too high', () => {
@ -1024,6 +1101,12 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.',
)
})
}) })
describe('creation is valid', () => { describe('creation is valid', () => {
@ -1039,6 +1122,15 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('stores the admin create contribution event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.ADMIN_CONTRIBUTION_CREATE,
userId: admin.id,
}),
)
})
}) })
describe('second creation surpasses the available amount ', () => { describe('second creation surpasses the available amount ', () => {
@ -1056,6 +1148,12 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.',
)
})
}) })
}) })
}) })
@ -1134,6 +1232,12 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'Could not find UserContact with email: bob@baumeister.de',
)
})
}) })
describe('user for creation to update is deleted', () => { describe('user for creation to update is deleted', () => {
@ -1155,6 +1259,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User was deleted (stephen@hawking.uk)')
})
}) })
describe('creation does not exist', () => { describe('creation does not exist', () => {
@ -1176,6 +1284,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('No contribution found to given id.')
})
}) })
describe('user email does not match creation user', () => { describe('user email does not match creation user', () => {
@ -1188,7 +1300,9 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: creation
? creation.contributionDate.toString()
: new Date().toString(),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1201,11 +1315,17 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'user of the pending contribution and send user does not correspond',
)
})
}) })
describe('creation update is not valid', () => { describe('creation update is not valid', () => {
// as this test has not clearly defined that date, it is a false positive // as this test has not clearly defined that date, it is a false positive
it.skip('throws an error', async () => { it('throws an error', async () => {
await expect( await expect(
mutate({ mutate({
mutation: adminUpdateContribution, mutation: adminUpdateContribution,
@ -1214,24 +1334,32 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: new Decimal(1900), amount: new Decimal(1900),
memo: 'Danke Peter!', memo: 'Danke Peter!',
creationDate: new Date().toString(), creationDate: creation
? creation.contributionDate.toString()
: new Date().toString(),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [
new GraphQLError( new GraphQLError(
'The amount (1900 GDD) to be created exceeds the amount (500 GDD) still available for this month.', 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.',
), ),
], ],
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.',
)
})
}) })
describe('creation update is successful changing month', () => { describe.skip('creation update is successful changing month', () => {
// skipped as changing the month is currently disable // skipped as changing the month is currently disable
it.skip('returns update creation object', async () => { it('returns update creation object', async () => {
await expect( await expect(
mutate({ mutate({
mutation: adminUpdateContribution, mutation: adminUpdateContribution,
@ -1240,7 +1368,9 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Peter!', memo: 'Danke Peter!',
creationDate: new Date().toString(), creationDate: creation
? creation.contributionDate.toString()
: new Date().toString(),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1250,17 +1380,26 @@ describe('AdminResolver', () => {
date: expect.any(String), date: expect.any(String),
memo: 'Danke Peter!', memo: 'Danke Peter!',
amount: '300', amount: '300',
creation: ['1000', '1000', '200'], creation: ['1000', '700', '500'],
}, },
}, },
}), }),
) )
}) })
it('stores the admin update contribution event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE,
userId: admin.id,
}),
)
})
}) })
describe('creation update is successful without changing month', () => { describe('creation update is successful without changing month', () => {
// actually this mutation IS changing the month // actually this mutation IS changing the month
it.skip('returns update creation object', async () => { it('returns update creation object', async () => {
await expect( await expect(
mutate({ mutate({
mutation: adminUpdateContribution, mutation: adminUpdateContribution,
@ -1269,7 +1408,9 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: new Decimal(200), amount: new Decimal(200),
memo: 'Das war leider zu Viel!', memo: 'Das war leider zu Viel!',
creationDate: new Date().toString(), creationDate: creation
? creation.contributionDate.toString()
: new Date().toString(),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1279,12 +1420,21 @@ describe('AdminResolver', () => {
date: expect.any(String), date: expect.any(String),
memo: 'Das war leider zu Viel!', memo: 'Das war leider zu Viel!',
amount: '200', amount: '200',
creation: ['1000', '1000', '300'], creation: ['1000', '800', '500'],
}, },
}, },
}), }),
) )
}) })
it('stores the admin update contribution event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE,
userId: admin.id,
}),
)
})
}) })
}) })
@ -1304,10 +1454,10 @@ describe('AdminResolver', () => {
lastName: 'Lustig', lastName: 'Lustig',
email: 'peter@lustig.de', email: 'peter@lustig.de',
date: expect.any(String), date: expect.any(String),
memo: 'Herzlich Willkommen bei Gradido!', memo: 'Das war leider zu Viel!',
amount: '400', amount: '200',
moderator: admin.id, moderator: admin.id,
creation: ['1000', '600', '500'], creation: ['1000', '800', '500'],
}, },
{ {
id: expect.any(Number), id: expect.any(Number),
@ -1318,7 +1468,7 @@ describe('AdminResolver', () => {
memo: 'Grundeinkommen', memo: 'Grundeinkommen',
amount: '500', amount: '500',
moderator: admin.id, moderator: admin.id,
creation: ['1000', '600', '500'], creation: ['1000', '800', '500'],
}, },
{ {
id: expect.any(Number), id: expect.any(Number),
@ -1365,6 +1515,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution not found for given id: -1')
})
}) })
describe('admin deletes own user contribution', () => { describe('admin deletes own user contribution', () => {
@ -1414,6 +1568,15 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('stores the admin delete contribution event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.ADMIN_CONTRIBUTION_DELETE,
userId: admin.id,
}),
)
})
}) })
}) })
@ -1433,6 +1596,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution not found for given id: -1')
})
}) })
describe('confirm own creation', () => { describe('confirm own creation', () => {
@ -1460,6 +1627,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Moderator can not confirm own contribution')
})
}) })
describe('confirm creation for other user', () => { describe('confirm creation for other user', () => {
@ -1488,6 +1659,14 @@ describe('AdminResolver', () => {
) )
}) })
it('stores the contribution confirm event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.CONTRIBUTION_CONFIRM,
}),
)
})
it('creates a transaction', async () => { it('creates a transaction', async () => {
const transaction = await DbTransaction.find() const transaction = await DbTransaction.find()
expect(transaction[0].amount.toString()).toBe('450') expect(transaction[0].amount.toString()).toBe('450')
@ -1512,6 +1691,14 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('stores the send confirmation email event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.SEND_CONFIRMATION_EMAIL,
}),
)
})
}) })
describe('confirm two creations one after the other quickly', () => { describe('confirm two creations one after the other quickly', () => {
@ -2052,6 +2239,12 @@ describe('AdminResolver', () => {
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'Start-Date is not initialized. A Start-Date must be set!',
)
})
it('returns an error if missing endDate', async () => { it('returns an error if missing endDate', async () => {
await expect( await expect(
mutate({ mutate({
@ -2068,6 +2261,12 @@ describe('AdminResolver', () => {
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'End-Date is not initialized. An End-Date must be set!',
)
})
it('returns an error if endDate is before startDate', async () => { it('returns an error if endDate is before startDate', async () => {
await expect( await expect(
mutate({ mutate({
@ -2087,6 +2286,12 @@ describe('AdminResolver', () => {
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of validFrom must before or equals the validTo!`,
)
})
it('returns an error if name is an empty string', async () => { it('returns an error if name is an empty string', async () => {
await expect( await expect(
mutate({ mutate({
@ -2103,6 +2308,10 @@ describe('AdminResolver', () => {
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The name must be initialized!')
})
it('returns an error if name is shorter than 5 characters', async () => { it('returns an error if name is shorter than 5 characters', async () => {
await expect( await expect(
mutate({ mutate({
@ -2123,6 +2332,12 @@ describe('AdminResolver', () => {
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`,
)
})
it('returns an error if name is longer than 100 characters', async () => { it('returns an error if name is longer than 100 characters', async () => {
await expect( await expect(
mutate({ mutate({
@ -2143,6 +2358,12 @@ describe('AdminResolver', () => {
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`,
)
})
it('returns an error if memo is an empty string', async () => { it('returns an error if memo is an empty string', async () => {
await expect( await expect(
mutate({ mutate({
@ -2159,6 +2380,10 @@ describe('AdminResolver', () => {
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The memo must be initialized!')
})
it('returns an error if memo is shorter than 5 characters', async () => { it('returns an error if memo is shorter than 5 characters', async () => {
await expect( await expect(
mutate({ mutate({
@ -2179,6 +2404,12 @@ describe('AdminResolver', () => {
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`,
)
})
it('returns an error if memo is longer than 255 characters', async () => { it('returns an error if memo is longer than 255 characters', async () => {
await expect( await expect(
mutate({ mutate({
@ -2199,6 +2430,12 @@ describe('AdminResolver', () => {
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`,
)
})
it('returns an error if amount is not positive', async () => { it('returns an error if amount is not positive', async () => {
await expect( await expect(
mutate({ mutate({
@ -2216,6 +2453,12 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The amount=0 must be initialized with a positiv value!',
)
})
}) })
describe('listContributionLinks', () => { describe('listContributionLinks', () => {
@ -2271,6 +2514,10 @@ describe('AdminResolver', () => {
}) })
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1')
})
describe('valid id', () => { describe('valid id', () => {
let linkId: number let linkId: number
beforeAll(async () => { beforeAll(async () => {
@ -2336,6 +2583,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1')
})
}) })
describe('valid id', () => { describe('valid id', () => {

View File

@ -64,6 +64,15 @@ import { ContributionMessageType } from '@enum/MessageType'
import { ContributionMessage } from '@model/ContributionMessage' import { ContributionMessage } from '@model/ContributionMessage'
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail'
import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail' import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import {
Event,
EventAdminContributionCreate,
EventAdminContributionDelete,
EventAdminContributionUpdate,
EventContributionConfirm,
EventSendConfirmationEmail,
} from '@/event/Event'
import { ContributionListResult } from '../model/Contribution' import { ContributionListResult } from '../model/Contribution'
// const EMAIL_OPT_IN_REGISTER = 1 // const EMAIL_OPT_IN_REGISTER = 1
@ -145,11 +154,13 @@ export class AdminResolver {
const user = await dbUser.findOne({ id: userId }) const user = await dbUser.findOne({ id: userId })
// user exists ? // user exists ?
if (!user) { if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`)
} }
// administrator user changes own role? // administrator user changes own role?
const moderatorUser = getUser(context) const moderatorUser = getUser(context)
if (moderatorUser.id === userId) { if (moderatorUser.id === userId) {
logger.error('Administrator can not change his own role!')
throw new Error('Administrator can not change his own role!') throw new Error('Administrator can not change his own role!')
} }
// change isAdmin // change isAdmin
@ -158,6 +169,7 @@ export class AdminResolver {
if (isAdmin === true) { if (isAdmin === true) {
user.isAdmin = new Date() user.isAdmin = new Date()
} else { } else {
logger.error('User is already a usual user!')
throw new Error('User is already a usual user!') throw new Error('User is already a usual user!')
} }
break break
@ -165,6 +177,7 @@ export class AdminResolver {
if (isAdmin === false) { if (isAdmin === false) {
user.isAdmin = null user.isAdmin = null
} else { } else {
logger.error('User is already admin!')
throw new Error('User is already admin!') throw new Error('User is already admin!')
} }
break break
@ -183,11 +196,13 @@ export class AdminResolver {
const user = await dbUser.findOne({ id: userId }) const user = await dbUser.findOne({ id: userId })
// user exists ? // user exists ?
if (!user) { if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`)
} }
// moderator user disabled own account? // moderator user disabled own account?
const moderatorUser = getUser(context) const moderatorUser = getUser(context)
if (moderatorUser.id === userId) { if (moderatorUser.id === userId) {
logger.error('Moderator can not delete his own account!')
throw new Error('Moderator can not delete his own account!') throw new Error('Moderator can not delete his own account!')
} }
// soft-delete user // soft-delete user
@ -201,9 +216,11 @@ export class AdminResolver {
async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise<Date | null> { async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise<Date | null> {
const user = await dbUser.findOne({ id: userId }, { withDeleted: true }) const user = await dbUser.findOne({ id: userId }, { withDeleted: true })
if (!user) { if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`)
} }
if (!user.deletedAt) { if (!user.deletedAt) {
logger.error('User is not deleted')
throw new Error('User is not deleted') throw new Error('User is not deleted')
} }
await user.recover() await user.recover()
@ -240,6 +257,8 @@ export class AdminResolver {
logger.error('Contribution could not be saved, Email is not activated') logger.error('Contribution could not be saved, Email is not activated')
throw new Error('Contribution could not be saved, Email is not activated') throw new Error('Contribution could not be saved, Email is not activated')
} }
const event = new Event()
const moderator = getUser(context) const moderator = getUser(context)
logger.trace('moderator: ', moderator.id) logger.trace('moderator: ', moderator.id)
const creations = await getUserCreation(emailContact.userId) const creations = await getUserCreation(emailContact.userId)
@ -258,7 +277,17 @@ export class AdminResolver {
contribution.contributionStatus = ContributionStatus.PENDING contribution.contributionStatus = ContributionStatus.PENDING
logger.trace('contribution to save', contribution) logger.trace('contribution to save', contribution)
await DbContribution.save(contribution) await DbContribution.save(contribution)
const eventAdminCreateContribution = new EventAdminContributionCreate()
eventAdminCreateContribution.userId = moderator.id
eventAdminCreateContribution.amount = amount
eventAdminCreateContribution.contributionId = contribution.id
await eventProtocol.writeEvent(
event.setEventAdminContributionCreate(eventAdminCreateContribution),
)
return getUserCreation(emailContact.userId) return getUserCreation(emailContact.userId)
} }
@ -319,7 +348,6 @@ export class AdminResolver {
const contributionToUpdate = await DbContribution.findOne({ const contributionToUpdate = await DbContribution.findOne({
where: { id, confirmedAt: IsNull() }, where: { id, confirmedAt: IsNull() },
}) })
if (!contributionToUpdate) { if (!contributionToUpdate) {
logger.error('No contribution found to given id.') logger.error('No contribution found to given id.')
throw new Error('No contribution found to given id.') throw new Error('No contribution found to given id.')
@ -337,6 +365,7 @@ export class AdminResolver {
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id) let creations = await getUserCreation(user.id)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate) creations = updateCreations(creations, contributionToUpdate)
} else { } else {
@ -353,6 +382,7 @@ export class AdminResolver {
contributionToUpdate.contributionStatus = ContributionStatus.PENDING contributionToUpdate.contributionStatus = ContributionStatus.PENDING
await DbContribution.save(contributionToUpdate) await DbContribution.save(contributionToUpdate)
const result = new AdminUpdateContribution() const result = new AdminUpdateContribution()
result.amount = amount result.amount = amount
result.memo = contributionToUpdate.memo result.memo = contributionToUpdate.memo
@ -360,6 +390,15 @@ export class AdminResolver {
result.creation = await getUserCreation(user.id) result.creation = await getUserCreation(user.id)
const event = new Event()
const eventAdminContributionUpdate = new EventAdminContributionUpdate()
eventAdminContributionUpdate.userId = user.id
eventAdminContributionUpdate.amount = amount
eventAdminContributionUpdate.contributionId = contributionToUpdate.id
await eventProtocol.writeEvent(
event.setEventAdminContributionUpdate(eventAdminContributionUpdate),
)
return result return result
} }
@ -420,6 +459,16 @@ export class AdminResolver {
contribution.deletedBy = moderator.id contribution.deletedBy = moderator.id
await contribution.save() await contribution.save()
const res = await contribution.softRemove() const res = await contribution.softRemove()
const event = new Event()
const eventAdminContributionDelete = new EventAdminContributionDelete()
eventAdminContributionDelete.userId = contribution.userId
eventAdminContributionDelete.amount = contribution.amount
eventAdminContributionDelete.contributionId = contribution.id
await eventProtocol.writeEvent(
event.setEventAdminContributionDelete(eventAdminContributionDelete),
)
return !!res return !!res
} }
@ -515,6 +564,13 @@ export class AdminResolver {
} finally { } finally {
await queryRunner.release() await queryRunner.release()
} }
const event = new Event()
const eventContributionConfirm = new EventContributionConfirm()
eventContributionConfirm.userId = user.id
eventContributionConfirm.amount = contribution.amount
eventContributionConfirm.contributionId = contribution.id
await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm))
return true return true
} }
@ -576,6 +632,13 @@ export class AdminResolver {
// In case EMails are disabled log the activation link for the user // In case EMails are disabled log the activation link for the user
if (!emailSent) { if (!emailSent) {
logger.info(`Account confirmation link: ${activationLink}`) logger.info(`Account confirmation link: ${activationLink}`)
} else {
const event = new Event()
const eventSendConfirmationEmail = new EventSendConfirmationEmail()
eventSendConfirmationEmail.userId = user.id
await eventProtocol.writeEvent(
event.setEventSendConfirmationEmail(eventSendConfirmationEmail),
)
} }
return true return true
@ -768,9 +831,11 @@ export class AdminResolver {
relations: ['user'], relations: ['user'],
}) })
if (!contribution) { if (!contribution) {
logger.error('Contribution not found')
throw new Error('Contribution not found') throw new Error('Contribution not found')
} }
if (contribution.userId === user.id) { if (contribution.userId === user.id) {
logger.error('Admin can not answer on own contribution')
throw new Error('Admin can not answer on own contribution') throw new Error('Admin can not answer on own contribution')
} }
if (!contribution.user.emailContact) { if (!contribution.user.emailContact) {

View File

@ -6,8 +6,15 @@ import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig' import { peterLustig } from '@/seeds/users/peter-lustig'
import { cleanDB, testEnvironment } from '@test/helpers' import { cleanDB, testEnvironment } from '@test/helpers'
import { userFactory } from '@/seeds/factory/user' import { userFactory } from '@/seeds/factory/user'
import { login, createContributionLink, redeemTransactionLink } from '@/seeds/graphql/mutations' import {
login,
createContributionLink,
redeemTransactionLink,
createContribution,
updateContribution,
} from '@/seeds/graphql/mutations'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
@ -32,6 +39,7 @@ describe('TransactionLinkResolver', () => {
describe('redeem daily Contribution Link', () => { describe('redeem daily Contribution Link', () => {
const now = new Date() const now = new Date()
let contributionLink: DbContributionLink | undefined let contributionLink: DbContributionLink | undefined
let contribution: UnconfirmedContribution | undefined
beforeAll(async () => { beforeAll(async () => {
await mutate({ await mutate({
@ -79,56 +87,59 @@ describe('TransactionLinkResolver', () => {
) )
}) })
it('allows the user to redeem the contribution link', async () => { describe('user has pending contribution of 1000 GDD', () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
data: {
redeemTransactionLink: true,
},
errors: undefined,
})
})
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
),
],
})
})
describe('after one day', () => {
beforeAll(async () => { beforeAll(async () => {
jest.useFakeTimers()
/* eslint-disable-next-line @typescript-eslint/no-empty-function */
setTimeout(() => {}, 1000 * 60 * 60 * 24)
jest.runAllTimers()
await mutate({ await mutate({
mutation: login, mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
const result = await mutate({
mutation: createContribution,
variables: {
amount: new Decimal(1000),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
},
})
contribution = result.data.createContribution
})
it('does not allow the user to redeem the contribution link', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: The amount (5 GDD) to be created exceeds the amount (0 GDD) still available for this month.',
),
],
})
})
})
describe('user has no pending contributions that would not allow to redeem the link', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
mutation: updateContribution,
variables: {
contributionId: contribution ? contribution.id : -1,
amount: new Decimal(800),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
},
}) })
}) })
afterAll(() => { it('allows the user to redeem the contribution link', async () => {
jest.useRealTimers()
})
it('allows the user to redeem the contribution link again', async () => {
await expect( await expect(
mutate({ mutate({
mutation: redeemTransactionLink, mutation: redeemTransactionLink,
@ -160,6 +171,56 @@ describe('TransactionLinkResolver', () => {
], ],
}) })
}) })
describe('after one day', () => {
beforeAll(async () => {
jest.useFakeTimers()
/* eslint-disable-next-line @typescript-eslint/no-empty-function */
setTimeout(() => {}, 1000 * 60 * 60 * 24)
jest.runAllTimers()
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(() => {
jest.useRealTimers()
})
it('allows the user to redeem the contribution link again', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
data: {
redeemTransactionLink: true,
},
errors: undefined,
})
})
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
),
],
})
})
})
}) })
}) })
}) })

View File

@ -74,10 +74,7 @@ export class TransactionLinkResolver {
const holdAvailableAmount = amount.minus(calculateDecay(amount, createdDate, validUntil).decay) const holdAvailableAmount = amount.minus(calculateDecay(amount, createdDate, validUntil).decay)
// validate amount // validate amount
const sendBalance = await calculateBalance(user.id, holdAvailableAmount.mul(-1), createdDate) await calculateBalance(user.id, holdAvailableAmount, createdDate)
if (!sendBalance) {
throw new Error("user hasn't enough GDD or amount is < 0")
}
const transactionLink = dbTransactionLink.create() const transactionLink = dbTransactionLink.create()
transactionLink.userId = user.id transactionLink.userId = user.id
@ -261,7 +258,7 @@ export class TransactionLinkResolver {
} }
} }
const creations = await getUserCreation(user.id, false) const creations = await getUserCreation(user.id)
logger.info('open creations', creations) logger.info('open creations', creations)
validateContribution(creations, contributionLink.amount, now) validateContribution(creations, contributionLink.amount, now)
const contribution = new DbContribution() const contribution = new DbContribution()

View File

@ -0,0 +1,366 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { EventProtocolType } from '@/event/EventProtocolType'
import { userFactory } from '@/seeds/factory/user'
import {
confirmContribution,
createContribution,
login,
sendCoins,
} from '@/seeds/graphql/mutations'
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
import { garrickOllivander } from '@/seeds/users/garrick-ollivander'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { stephenHawking } from '@/seeds/users/stephen-hawking'
import { EventProtocol } from '@entity/EventProtocol'
import { Transaction } from '@entity/Transaction'
import { User } from '@entity/User'
import { cleanDB, resetToken, testEnvironment } from '@test/helpers'
import { logger } from '@test/testSetup'
import { GraphQLError } from 'graphql'
import { findUserByEmail } from './UserResolver'
let mutate: any, query: any, con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment(logger)
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
let bobData: any
let peterData: any
let user: User[]
describe('send coins', () => {
beforeAll(async () => {
await userFactory(testEnv, peterLustig)
await userFactory(testEnv, bobBaumeister)
await userFactory(testEnv, stephenHawking)
await userFactory(testEnv, garrickOllivander)
bobData = {
email: 'bob@baumeister.de',
password: 'Aa12345_',
}
peterData = {
email: 'peter@lustig.de',
password: 'Aa12345_',
}
user = await User.find({ relations: ['emailContact'] })
})
afterAll(async () => {
await cleanDB()
})
describe('unknown recipient', () => {
it('throws an error', async () => {
await mutate({
mutation: login,
variables: bobData,
})
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'wrong@email.com',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('No user with this credentials')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`UserContact with email=wrong@email.com does not exists`)
})
describe('deleted recipient', () => {
it('throws an error', async () => {
await mutate({
mutation: login,
variables: peterData,
})
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'stephen@hawking.uk',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('The recipient account was deleted')],
}),
)
})
it('logs the error thrown', async () => {
// find peter to check the log
const user = await findUserByEmail(peterData.email)
expect(logger.error).toBeCalledWith(
`The recipient account was deleted: recipientUser=${user}`,
)
})
})
describe('recipient account not activated', () => {
it('throws an error', async () => {
await mutate({
mutation: login,
variables: peterData,
})
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'garrick@ollivander.com',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('The recipient account is not activated')],
}),
)
})
it('logs the error thrown', async () => {
// find peter to check the log
const user = await findUserByEmail(peterData.email)
expect(logger.error).toBeCalledWith(
`The recipient account is not activated: recipientUser=${user}`,
)
})
})
})
describe('errors in the transaction itself', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: bobData,
})
})
describe('sender and recipient are the same', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'bob@baumeister.de',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('Sender and Recipient are the same.')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Sender and Recipient are the same.')
})
})
describe('memo text is too long', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: 100,
memo: 'test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test t',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('memo text is too long (255 characters maximum)')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('memo text is too long: memo.length=256 > 255')
})
})
describe('memo text is too short', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('memo text is too short (5 characters minimum)')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < 5')
})
})
describe('user has not enough GDD', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: 100,
memo: 'testing',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError(`User has not received any GDD yet`)],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`No prior transaction found for user with id: ${user[1].id}`,
)
})
})
describe('sending negative amount', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: -50,
memo: 'testing negative',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('Transaction amount must be greater than 0')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Transaction amount must be greater than 0: -50')
})
})
})
describe('user has some GDD', () => {
beforeAll(async () => {
resetToken()
// login as bob again
await query({ mutation: login, variables: bobData })
// create contribution as user bob
const contribution = await mutate({
mutation: createContribution,
variables: { amount: 1000, memo: 'testing', creationDate: new Date().toISOString() },
})
// login as admin
await query({ mutation: login, variables: peterData })
// confirm the contribution
await mutate({
mutation: confirmContribution,
variables: { id: contribution.data.createContribution.id },
})
// login as bob again
await query({ mutation: login, variables: bobData })
})
describe('good transaction', () => {
it('sends the coins', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: 50,
memo: 'unrepeatable memo',
},
}),
).toEqual(
expect.objectContaining({
data: {
sendCoins: 'true',
},
}),
)
})
it('stores the send transaction event in the database', async () => {
// Find the exact transaction (sent one is the one with user[1] as user)
const transaction = await Transaction.find({
userId: user[1].id,
memo: 'unrepeatable memo',
})
expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.TRANSACTION_SEND,
userId: user[1].id,
transactionId: transaction[0].id,
xUserId: user[0].id,
}),
)
})
it('stores the receive event in the database', async () => {
// Find the exact transaction (received one is the one with user[0] as user)
const transaction = await Transaction.find({
userId: user[0].id,
memo: 'unrepeatable memo',
})
expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.TRANSACTION_RECEIVE,
userId: user[0].id,
transactionId: transaction[0].id,
xUserId: user[1].id,
}),
)
})
})
})
})

View File

@ -37,6 +37,9 @@ import { BalanceResolver } from './BalanceResolver'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
import { findUserByEmail } from './UserResolver' import { findUserByEmail } from './UserResolver'
import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed' import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed'
import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import { Decay } from '../model/Decay'
export const executeTransaction = async ( export const executeTransaction = async (
amount: Decimal, amount: Decimal,
@ -55,28 +58,19 @@ export const executeTransaction = async (
} }
if (memo.length > MEMO_MAX_CHARS) { if (memo.length > MEMO_MAX_CHARS) {
logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`) logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`)
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
} }
if (memo.length < MEMO_MIN_CHARS) { if (memo.length < MEMO_MIN_CHARS) {
logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`) logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`)
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`) throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
} }
// validate amount // validate amount
const receivedCallDate = new Date() const receivedCallDate = new Date()
const sendBalance = await calculateBalance(
sender.id, const sendBalance = await calculateBalance(sender.id, amount, receivedCallDate, transactionLink)
amount.mul(-1),
receivedCallDate,
transactionLink,
)
logger.debug(`calculated Balance=${sendBalance}`)
if (!sendBalance) {
logger.error(`user hasn't enough GDD or amount is < 0 : balance=${sendBalance}`)
throw new Error("user hasn't enough GDD or amount is < 0")
}
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect() await queryRunner.connect()
@ -106,7 +100,24 @@ export const executeTransaction = async (
transactionReceive.userId = recipient.id transactionReceive.userId = recipient.id
transactionReceive.linkedUserId = sender.id transactionReceive.linkedUserId = sender.id
transactionReceive.amount = amount transactionReceive.amount = amount
const receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate)
// state received balance
let receiveBalance: {
balance: Decimal
decay: Decay
lastTransactionId: number
} | null
// try received balance
try {
receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate)
} catch (e) {
logger.info(
`User with no transactions sent: ${recipient.id}, has received a transaction of ${amount} GDD from user: ${sender.id}`,
)
receiveBalance = null
}
transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount
transactionReceive.balanceDate = receivedCallDate transactionReceive.balanceDate = receivedCallDate
transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0) transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0)
@ -135,6 +146,20 @@ export const executeTransaction = async (
await queryRunner.commitTransaction() await queryRunner.commitTransaction()
logger.info(`commit Transaction successful...`) logger.info(`commit Transaction successful...`)
const eventTransactionSend = new EventTransactionSend()
eventTransactionSend.userId = transactionSend.userId
eventTransactionSend.xUserId = transactionSend.linkedUserId
eventTransactionSend.transactionId = transactionSend.id
eventTransactionSend.amount = transactionSend.amount.mul(-1)
await eventProtocol.writeEvent(new Event().setEventTransactionSend(eventTransactionSend))
const eventTransactionReceive = new EventTransactionReceive()
eventTransactionReceive.userId = transactionReceive.userId
eventTransactionReceive.xUserId = transactionReceive.linkedUserId
eventTransactionReceive.transactionId = transactionReceive.id
eventTransactionReceive.amount = transactionReceive.amount
await eventProtocol.writeEvent(new Event().setEventTransactionReceive(eventTransactionReceive))
} catch (e) { } catch (e) {
await queryRunner.rollbackTransaction() await queryRunner.rollbackTransaction()
logger.error(`Transaction was not successful: ${e}`) logger.error(`Transaction was not successful: ${e}`)
@ -316,6 +341,10 @@ export class TransactionResolver {
} }
*/ */
// const recipientUser = await dbUser.findOne({ id: emailContact.userId }) // const recipientUser = await dbUser.findOne({ id: emailContact.userId })
/* Code inside this if statement is unreachable (useless by so),
in findUserByEmail() an error is already thrown if the user is not found
*/
if (!recipientUser) { if (!recipientUser) {
logger.error(`unknown recipient to UserContact: email=${email}`) logger.error(`unknown recipient to UserContact: email=${email}`)
throw new Error('unknown recipient') throw new Error('unknown recipient')

View File

@ -1,4 +1,3 @@
import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { getConnection } from '@dbTools/typeorm' import { getConnection } from '@dbTools/typeorm'
import { Contribution } from '@entity/Contribution' import { Contribution } from '@entity/Contribution'
@ -50,27 +49,27 @@ export const getUserCreations = async (
const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day' const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day'
logger.trace('getUserCreations dateFilter=', dateFilter) logger.trace('getUserCreations dateFilter=', dateFilter)
const unionString = includePending const sumAmountContributionPerUserAndLast3MonthQuery = queryRunner.manager
? ` .createQueryBuilder(Contribution, 'c')
UNION .select('month(contribution_date)', 'month')
SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions .addSelect('user_id', 'userId')
WHERE user_id IN (${ids.toString()}) .addSelect('sum(amount)', 'sum')
AND contribution_date >= ${dateFilter} .where(`user_id in (${ids.toString()})`)
AND confirmed_at IS NULL AND deleted_at IS NULL` .andWhere(`contribution_date >= ${dateFilter}`)
: '' .andWhere('deleted_at IS NULL')
logger.trace('getUserCreations unionString=', unionString) .andWhere('denied_at IS NULL')
.groupBy('month')
.addGroupBy('userId')
.orderBy('month', 'DESC')
const unionQuery = await queryRunner.manager.query(` if (!includePending) {
SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM sumAmountContributionPerUserAndLast3MonthQuery.andWhere('confirmed_at IS NOT NULL')
(SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions }
WHERE user_id IN (${ids.toString()})
AND type_id = ${TransactionTypeId.CREATION} const sumAmountContributionPerUserAndLast3Month =
AND creation_date >= ${dateFilter} await sumAmountContributionPerUserAndLast3MonthQuery.getRawMany()
${unionString}) AS result
GROUP BY month, userId logger.trace(sumAmountContributionPerUserAndLast3Month)
ORDER BY date DESC
`)
logger.trace('getUserCreations unionQuery=', unionQuery)
await queryRunner.release() await queryRunner.release()
@ -78,9 +77,9 @@ export const getUserCreations = async (
return { return {
id, id,
creations: months.map((month) => { creations: months.map((month) => {
const creation = unionQuery.find( const creation = sumAmountContributionPerUserAndLast3Month.find(
(raw: { month: string; id: string; creation: number[] }) => (raw: { month: string; userId: string; creation: number[] }) =>
parseInt(raw.month) === month && parseInt(raw.id) === id, parseInt(raw.month) === month && parseInt(raw.userId) === id,
) )
return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0) return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0)
}), }),

View File

@ -26,7 +26,7 @@ describe('sendAddedContributionMessageEmail', () => {
it('calls sendEMail', () => { it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: `Bibi Bloxberg <bibi@bloxberg.de>`, to: `Bibi Bloxberg <bibi@bloxberg.de>`,
subject: 'Gradido Frage zur Schöpfung', subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag',
text: text:
expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining('Peter Lustig') && expect.stringContaining('Peter Lustig') &&

View File

@ -1,6 +1,6 @@
export const contributionMessageReceived = { export const contributionMessageReceived = {
de: { de: {
subject: 'Gradido Frage zur Schöpfung', subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag',
text: (data: { text: (data: {
senderFirstName: string senderFirstName: string
senderLastName: string senderLastName: string

View File

@ -28,8 +28,8 @@ export class UserRepository extends Repository<DbUser> {
): Promise<[DbUser[], number]> { ): Promise<[DbUser[], number]> {
const query = this.createQueryBuilder('user') const query = this.createQueryBuilder('user')
.select(select) .select(select)
.leftJoinAndSelect('user.emailContact', 'emailContact')
.withDeleted() .withDeleted()
.leftJoinAndSelect('user.emailContact', 'emailContact')
.where( .where(
new Brackets((qb) => { new Brackets((qb) => {
qb.where( qb.where(

View File

@ -1,5 +1,17 @@
import Decimal from 'decimal.js-light'
export const objectValuesToArray = (obj: { [x: string]: string }): Array<string> => { export const objectValuesToArray = (obj: { [x: string]: string }): Array<string> => {
return Object.keys(obj).map(function (key) { return Object.keys(obj).map(function (key) {
return obj[key] return obj[key]
}) })
} }
// to improve code readability, as String is needed, it is handled inside this utility function
export const decimalAddition = (a: Decimal, b: Decimal): Decimal => {
return a.add(b.toString())
}
// to improve code readability, as String is needed, it is handled inside this utility function
export const decimalSubtraction = (a: Decimal, b: Decimal): Decimal => {
return a.minus(b.toString())
}

View File

@ -5,6 +5,8 @@ import { Decay } from '@model/Decay'
import { getCustomRepository } from '@dbTools/typeorm' import { getCustomRepository } from '@dbTools/typeorm'
import { TransactionLinkRepository } from '@repository/TransactionLink' import { TransactionLinkRepository } from '@repository/TransactionLink'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { decimalSubtraction, decimalAddition } from './utilities'
import { backendLogger as logger } from '@/server/logger'
function isStringBoolean(value: string): boolean { function isStringBoolean(value: string): boolean {
const lowerValue = value.toLowerCase() const lowerValue = value.toLowerCase()
@ -23,14 +25,26 @@ async function calculateBalance(
amount: Decimal, amount: Decimal,
time: Date, time: Date,
transactionLink?: dbTransactionLink | null, transactionLink?: dbTransactionLink | null,
): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> { ): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number }> {
// negative or empty amount should not be allowed
if (amount.lessThanOrEqualTo(0)) {
logger.error(`Transaction amount must be greater than 0: ${amount}`)
throw new Error('Transaction amount must be greater than 0')
}
// check if user has prior transactions
const lastTransaction = await Transaction.findOne({ userId }, { order: { balanceDate: 'DESC' } }) const lastTransaction = await Transaction.findOne({ userId }, { order: { balanceDate: 'DESC' } })
if (!lastTransaction) return null
if (!lastTransaction) {
logger.error(`No prior transaction found for user with id: ${userId}`)
throw new Error('User has not received any GDD yet')
}
const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time) const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time)
// TODO why we have to use toString() here? // new balance is the old balance minus the amount used
const balance = decay.balance.add(amount.toString()) const balance = decimalSubtraction(decay.balance, amount)
const transactionLinkRepository = getCustomRepository(TransactionLinkRepository) const transactionLinkRepository = getCustomRepository(TransactionLinkRepository)
const { sumHoldAvailableAmount } = await transactionLinkRepository.summary(userId, time) const { sumHoldAvailableAmount } = await transactionLinkRepository.summary(userId, time)
@ -38,11 +52,16 @@ async function calculateBalance(
// else we cannot redeem links which are more or equal to half of what an account actually owns // else we cannot redeem links which are more or equal to half of what an account actually owns
const releasedLinkAmount = transactionLink ? transactionLink.holdAvailableAmount : new Decimal(0) const releasedLinkAmount = transactionLink ? transactionLink.holdAvailableAmount : new Decimal(0)
if ( const availableBalance = decimalSubtraction(balance, sumHoldAvailableAmount)
balance.minus(sumHoldAvailableAmount.toString()).plus(releasedLinkAmount.toString()).lessThan(0)
) { if (decimalAddition(availableBalance, releasedLinkAmount).lessThan(0)) {
return null logger.error(
`Not enough funds for a transaction of ${amount} GDD, user with id: ${userId} has only ${balance} GDD available`,
)
throw new Error('Not enough funds for transaction')
} }
logger.debug(`calculated Balance=${balance}`)
return { balance, lastTransactionId: lastTransaction.id, decay } return { balance, lastTransactionId: lastTransaction.id, decay }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "gradido-database", "name": "gradido-database",
"version": "1.13.1", "version": "1.13.2",
"description": "Gradido Database Tool to execute database migrations", "description": "Gradido Database Tool to execute database migrations",
"main": "src/index.ts", "main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/database", "repository": "https://github.com/gradido/gradido/database",

View File

@ -1,6 +1,6 @@
{ {
"name": "bootstrap-vue-gradido-wallet", "name": "bootstrap-vue-gradido-wallet",
"version": "1.13.1", "version": "1.13.2",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "node run/server.js", "start": "node run/server.js",

View File

@ -175,4 +175,68 @@ describe('ContributionMessagesListItem', () => {
}) })
}) })
}) })
describe('links in contribtion message', () => {
const propsData = {
message: {
id: 111,
message: 'Lorem ipsum?',
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const ModeratorItemWrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
let messageField
describe('message of only one link', () => {
beforeEach(() => {
propsData.message.message = 'https://gradido.net/de/'
wrapper = ModeratorItemWrapper()
messageField = wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)')
})
it('contains the link as text', () => {
expect(messageField.text()).toBe('https://gradido.net/de/')
})
it('contains a link to the given address', () => {
expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/')
})
})
describe('message with text and two links', () => {
beforeEach(() => {
propsData.message.message = `Here you find all you need to know about Gradido: https://gradido.net/de/
and here is the link to the repository: https://github.com/gradido/gradido`
wrapper = ModeratorItemWrapper()
messageField = wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)')
})
it('contains the whole text', () => {
expect(messageField.text())
.toBe(`Here you find all you need to know about Gradido: https://gradido.net/de/
and here is the link to the repository: https://github.com/gradido/gradido`)
})
it('contains the two links', () => {
expect(messageField.findAll('a').at(0).attributes('href')).toBe('https://gradido.net/de/')
expect(messageField.findAll('a').at(1).attributes('href')).toBe(
'https://github.com/gradido/gradido',
)
})
})
})
}) })

View File

@ -1,24 +1,29 @@
<template> <template>
<div class="contribution-messages-list-item"> <div class="contribution-messages-list-item">
<div v-if="isNotModerator" class="is-not-moderator text-right"> <div v-if="isNotModerator" class="is-not-moderator text-right">
<b-avatar :text="initialLetters" variant="info"></b-avatar> <b-avatar variant="info"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<div class="mt-2">{{ message.message }}</div> <linkify-message :message="message.message"></linkify-message>
</div> </div>
<div v-else class="is-moderator text-left"> <div v-else class="is-moderator text-left">
<b-avatar square :text="initialLetters" variant="warning"></b-avatar> <b-avatar square variant="warning"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<small class="ml-4 text-success">{{ $t('community.moderator') }}</small> <small class="ml-4 text-success">{{ $t('community.moderator') }}</small>
<div class="mt-2">{{ message.message }}</div> <linkify-message :message="message.message"></linkify-message>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import LinkifyMessage from '@/components/ContributionMessages/LinkifyMessage.vue'
export default { export default {
name: 'ContributionMessagesListItem', name: 'ContributionMessagesListItem',
components: {
LinkifyMessage,
},
props: { props: {
message: { message: {
type: Object, type: Object,

View File

@ -0,0 +1,37 @@
<template>
<div class="mt-2">
<span v-for="({ type, text }, index) in linkifiedMessage" :key="index">
<b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link>
<span v-else>{{ text }}</span>
</span>
</div>
</template>
<script>
const LINK_REGEX_PATTERN = /(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))/i
export default {
name: 'LinkifyMessage',
props: {
message: {
type: String,
required: true,
},
},
computed: {
linkifiedMessage() {
const linkified = []
let string = this.message
let match
while ((match = string.match(LINK_REGEX_PATTERN))) {
if (match.index > 0)
linkified.push({ type: 'text', text: string.substring(0, match.index) })
linkified.push({ type: 'link', text: match[0] })
string = string.substring(match.index + match[0].length)
}
if (string.length > 0) linkified.push({ type: 'text', text: string })
return linkified
},
},
}
</script>

View File

@ -88,6 +88,8 @@
</div> </div>
</template> </template>
<script> <script>
const PATTERN_NON_DIGIT = /\D/g
export default { export default {
name: 'ContributionForm', name: 'ContributionForm',
props: { props: {
@ -104,10 +106,10 @@ export default {
}, },
methods: { methods: {
numberFormat(value) { numberFormat(value) {
return value.replace(/\D/g, '') return value.replace(PATTERN_NON_DIGIT, '')
}, },
submit() { submit() {
this.form.amount = this.numberFormat(this.form.amount) this.form.amount = this.form.amount.replace(PATTERN_NON_DIGIT, '')
// spreading is needed for testing // spreading is needed for testing
this.$emit(this.form.id ? 'update-contribution' : 'set-contribution', { ...this.form }) this.$emit(this.form.id ? 'update-contribution' : 'set-contribution', { ...this.form })
this.reset() this.reset()

View File

@ -34,7 +34,7 @@
"contribution": { "contribution": {
"activity": "Tätigkeit", "activity": "Tätigkeit",
"alert": { "alert": {
"answerQuestion": "Bitte beantworte die Nachfrage", "answerQuestion": "Bitte beantworte die Rückfrage!",
"communityNoteList": "Hier findest du alle eingereichten und bestätigten Beiträge von allen Mitgliedern aus dieser Gemeinschaft.", "communityNoteList": "Hier findest du alle eingereichten und bestätigten Beiträge von allen Mitgliedern aus dieser Gemeinschaft.",
"confirm": "bestätigt", "confirm": "bestätigt",
"in_progress": "Es gibt eine Rückfrage der Moderatoren.", "in_progress": "Es gibt eine Rückfrage der Moderatoren.",

View File

@ -231,8 +231,6 @@ export default {
this.items = listContributions.contributionList this.items = listContributions.contributionList
if (this.items.find((item) => item.state === 'IN_PROGRESS')) { if (this.items.find((item) => item.state === 'IN_PROGRESS')) {
this.tabIndex = 1 this.tabIndex = 1
} else {
this.tabIndex = 0
} }
}) })
.catch((err) => { .catch((err) => {

View File

@ -1,6 +1,6 @@
{ {
"name": "gradido", "name": "gradido",
"version": "1.13.1", "version": "1.13.2",
"description": "Gradido", "description": "Gradido",
"main": "index.js", "main": "index.js",
"repository": "git@github.com:gradido/gradido.git", "repository": "git@github.com:gradido/gradido.git",