Merge branch 'master' into 2267-feature-gradido-roadmap

This commit is contained in:
clauspeterhuebner 2022-10-25 22:51:00 +02:00 committed by GitHub
commit 039d62432e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1355 additions and 141 deletions

View File

@ -4,8 +4,54 @@ 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.1](https://github.com/gradido/gradido/compare/1.13.0...1.13.1)
- 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)
#### [1.13.0](https://github.com/gradido/gradido/compare/1.12.1...1.13.0)
> 18 October 2022
- release: Version 1.13.0 [`#2269`](https://github.com/gradido/gradido/pull/2269)
- fix: Linked User Email in Transaction List [`#2268`](https://github.com/gradido/gradido/pull/2268)
- concept capturing alias [`#2148`](https://github.com/gradido/gradido/pull/2148)
- fix: 🍰 Daily Redeem Of Contribution Link [`#2265`](https://github.com/gradido/gradido/pull/2265)
- fix: 🐛 Prevent Loosing Redeem Code When Changing Between Register and Login in Auth Navbar [`#2260`](https://github.com/gradido/gradido/pull/2260)
- fix: Disable Change of Month on Update Contribution [`#2264`](https://github.com/gradido/gradido/pull/2264)
- feat: 🍰 Global Jest Extension For Decimal Equal [`#2261`](https://github.com/gradido/gradido/pull/2261)
- feat: 🍰 Daily Rule For Contribution Links In Admin Interface [`#2262`](https://github.com/gradido/gradido/pull/2262)
- feat: 🍰 Do Not Show Expired Contribution Links In Wallet [`#2257`](https://github.com/gradido/gradido/pull/2257)
- fix: 🍰 Disable Change Of Month For Update Contribution (wallet and admin) [`#2258`](https://github.com/gradido/gradido/pull/2258)
- refactor: 🍰 Login And Logout To Mutations [`#2232`](https://github.com/gradido/gradido/pull/2232)
- fix: 🐛 Verify Token Before Redeeming A Link [`#2254`](https://github.com/gradido/gradido/pull/2254)
- Refactor: Add all events to documentation table [`#2240`](https://github.com/gradido/gradido/pull/2240)
- reconfig log4js with rollover feature and userid in logevent-message [`#2221`](https://github.com/gradido/gradido/pull/2221)
- refactor: 🍰 Refactoring Components Of `CotributionMessagesListItem` [`#2251`](https://github.com/gradido/gradido/pull/2251)
- style: add border-radius on send form [`#2233`](https://github.com/gradido/gradido/pull/2233)
- 2198 adminarea more dates on created transaction [`#2212`](https://github.com/gradido/gradido/pull/2212)
- Bug: delete contribution link [`#2213`](https://github.com/gradido/gradido/pull/2213)
- chore: 🍰 Fix Cypress Tests Unreliability [`#2245`](https://github.com/gradido/gradido/pull/2245)
- docs: 🍰 Refine Deployment Documentation [`#2209`](https://github.com/gradido/gradido/pull/2209)
- End-to-end test setup [`#2047`](https://github.com/gradido/gradido/pull/2047)
- config testmodus flag for sending emails to test or team account instead of user account [`#2216`](https://github.com/gradido/gradido/pull/2216)
- GradidoID 1: adapt and migrate database schema [`#2058`](https://github.com/gradido/gradido/pull/2058)
- feat: Add Client Request Time to Context [`#2206`](https://github.com/gradido/gradido/pull/2206)
- 2219 feature rework eventprotocol [`#2234`](https://github.com/gradido/gradido/pull/2234)
- Refactor: Test register with redeem code [`#2214`](https://github.com/gradido/gradido/pull/2214)
- 2203 delete query modal when redeeming the redeem link [`#2211`](https://github.com/gradido/gradido/pull/2211)
- Refactor: 🍰 Change email templates [`#2228`](https://github.com/gradido/gradido/pull/2228)
- Refactor: Events and logs completed in User Resolver [`#2204`](https://github.com/gradido/gradido/pull/2204)
- change support mail [`#2210`](https://github.com/gradido/gradido/pull/2210)
- feat: 🍰 Send email when contribution is confirmed [`#2193`](https://github.com/gradido/gradido/pull/2193)
- feat: 🍰 Send email when admin writes message to contribution [`#2187`](https://github.com/gradido/gradido/pull/2187)
- feat: 🍰 Send Email To Transaction Link Sender After Receiver Redeemed It [`#2063`](https://github.com/gradido/gradido/pull/2063)
#### [1.12.1](https://github.com/gradido/gradido/compare/1.12.0...1.12.1) #### [1.12.1](https://github.com/gradido/gradido/compare/1.12.0...1.12.1)
> 13 September 2022
- release: Version 1.12.1 [`#2196`](https://github.com/gradido/gradido/pull/2196)
- fix: 🍰 Show Not Icons In `allContribution` List [`#2195`](https://github.com/gradido/gradido/pull/2195) - fix: 🍰 Show Not Icons In `allContribution` List [`#2195`](https://github.com/gradido/gradido/pull/2195)
#### [1.12.0](https://github.com/gradido/gradido/compare/1.11.0...1.12.0) #### [1.12.0](https://github.com/gradido/gradido/compare/1.11.0...1.12.0)

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.12.1", "version": "1.13.1",
"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'" :to="text">{{ 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.12.1", "version": "1.13.1",
"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

@ -10,7 +10,7 @@ Decimal.set({
}) })
const constants = { const constants = {
DB_VERSION: '0049-add_user_contacts_table', DB_VERSION: '0051-add_delete_by_to_contributions',
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json', LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info // default log level on production should be info
@ -67,7 +67,7 @@ const loginServer = {
const email = { const email = {
EMAIL: process.env.EMAIL === 'true' || false, EMAIL: process.env.EMAIL === 'true' || false,
EMAIL_TEST_MODUS: process.env.EMAIL_TEST_MODUS === 'true' || 'false', EMAIL_TEST_MODUS: process.env.EMAIL_TEST_MODUS === 'true' || false,
EMAIL_TEST_RECEIVER: process.env.EMAIL_TEST_RECEIVER || 'stage1@gradido.net', EMAIL_TEST_RECEIVER: process.env.EMAIL_TEST_RECEIVER || 'stage1@gradido.net',
EMAIL_USERNAME: process.env.EMAIL_USERNAME || 'gradido_email', EMAIL_USERNAME: process.env.EMAIL_USERNAME || 'gradido_email',
EMAIL_SENDER: process.env.EMAIL_SENDER || 'info@gradido.net', EMAIL_SENDER: process.env.EMAIL_SENDER || 'info@gradido.net',

View File

@ -11,48 +11,67 @@ export class EventBasicUserId extends EventBasic {
} }
export class EventBasicTx extends EventBasicUserId { export class EventBasicTx extends EventBasicUserId {
xUserId: number
xCommunityId: number
transactionId: number transactionId: number
amount: decimal amount: decimal
} }
export class EventBasicTxX extends EventBasicTx {
xUserId: number
xCommunityId: number
}
export class EventBasicCt extends EventBasicUserId { export class EventBasicCt extends EventBasicUserId {
contributionId: number contributionId: number
amount: decimal amount: decimal
} }
export class EventBasicCtX extends EventBasicCt {
xUserId: number
xCommunityId: number
}
export class EventBasicRedeem extends EventBasicUserId { export class EventBasicRedeem extends EventBasicUserId {
transactionId?: number transactionId?: number
contributionId?: number contributionId?: number
} }
export class EventBasicCtMsg extends EventBasicCt {
messageId: number
}
export class EventVisitGradido extends EventBasic {} export class EventVisitGradido extends EventBasic {}
export class EventRegister extends EventBasicUserId {} export class EventRegister extends EventBasicUserId {}
export class EventRedeemRegister extends EventBasicRedeem {} export class EventRedeemRegister extends EventBasicRedeem {}
export class EventVerifyRedeem extends EventBasicRedeem {}
export class EventInactiveAccount extends EventBasicUserId {} export class EventInactiveAccount extends EventBasicUserId {}
export class EventSendConfirmationEmail extends EventBasicUserId {} export class EventSendConfirmationEmail extends EventBasicUserId {}
export class EventSendAccountMultiRegistrationEmail extends EventBasicUserId {} export class EventSendAccountMultiRegistrationEmail extends EventBasicUserId {}
export class EventSendForgotPasswordEmail extends EventBasicUserId {}
export class EventSendTransactionSendEmail extends EventBasicTxX {}
export class EventSendTransactionReceiveEmail extends EventBasicTxX {}
export class EventSendTransactionLinkRedeemEmail extends EventBasicTxX {}
export class EventSendAddedContributionEmail extends EventBasicCt {}
export class EventSendContributionConfirmEmail extends EventBasicCt {}
export class EventConfirmationEmail extends EventBasicUserId {} export class EventConfirmationEmail extends EventBasicUserId {}
export class EventRegisterEmailKlicktipp extends EventBasicUserId {} export class EventRegisterEmailKlicktipp extends EventBasicUserId {}
export class EventLogin extends EventBasicUserId {} export class EventLogin extends EventBasicUserId {}
export class EventLogout extends EventBasicUserId {}
export class EventRedeemLogin extends EventBasicRedeem {} export class EventRedeemLogin extends EventBasicRedeem {}
export class EventActivateAccount extends EventBasicUserId {} export class EventActivateAccount extends EventBasicUserId {}
export class EventPasswordChange extends EventBasicUserId {} export class EventPasswordChange extends EventBasicUserId {}
export class EventTransactionSend extends EventBasicTx {} export class EventTransactionSend extends EventBasicTxX {}
export class EventTransactionSendRedeem extends EventBasicTx {} export class EventTransactionSendRedeem extends EventBasicTxX {}
export class EventTransactionRepeateRedeem extends EventBasicTx {} export class EventTransactionRepeateRedeem extends EventBasicTxX {}
export class EventTransactionCreation extends EventBasicUserId { export class EventTransactionCreation extends EventBasicTx {}
transactionId: number export class EventTransactionReceive extends EventBasicTxX {}
amount: decimal export class EventTransactionReceiveRedeem extends EventBasicTxX {}
}
export class EventTransactionReceive extends EventBasicTx {}
export class EventTransactionReceiveRedeem extends EventBasicTx {}
export class EventContributionCreate extends EventBasicCt {} export class EventContributionCreate extends EventBasicCt {}
export class EventContributionConfirm extends EventBasicCt { export class EventUserCreateContributionMessage extends EventBasicCtMsg {}
xUserId: number export class EventAdminCreateContributionMessage extends EventBasicCtMsg {}
xCommunityId: number export class EventContributionDelete extends EventBasicCt {}
} export class EventContributionUpdate extends EventBasicCt {}
export class EventContributionConfirm 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 {}
@ -100,6 +119,13 @@ export class Event {
return this return this
} }
public setEventVerifyRedeem(ev: EventVerifyRedeem): Event {
this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId)
this.type = EventProtocolType.VERIFY_REDEEM
return this
}
public setEventInactiveAccount(ev: EventInactiveAccount): Event { public setEventInactiveAccount(ev: EventInactiveAccount): Event {
this.setByBasicUser(ev.userId) this.setByBasicUser(ev.userId)
this.type = EventProtocolType.INACTIVE_ACCOUNT this.type = EventProtocolType.INACTIVE_ACCOUNT
@ -118,7 +144,49 @@ export class Event {
ev: EventSendAccountMultiRegistrationEmail, ev: EventSendAccountMultiRegistrationEmail,
): Event { ): Event {
this.setByBasicUser(ev.userId) this.setByBasicUser(ev.userId)
this.type = EventProtocolType.SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL this.type = EventProtocolType.SEND_ACCOUNT_MULTIREGISTRATION_EMAIL
return this
}
public setEventSendForgotPasswordEmail(ev: EventSendForgotPasswordEmail): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.SEND_FORGOT_PASSWORD_EMAIL
return this
}
public setEventSendTransactionSendEmail(ev: EventSendTransactionSendEmail): Event {
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.SEND_TRANSACTION_SEND_EMAIL
return this
}
public setEventSendTransactionReceiveEmail(ev: EventSendTransactionReceiveEmail): Event {
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.SEND_TRANSACTION_RECEIVE_EMAIL
return this
}
public setEventSendTransactionLinkRedeemEmail(ev: EventSendTransactionLinkRedeemEmail): Event {
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.SEND_TRANSACTION_LINK_REDEEM_EMAIL
return this
}
public setEventSendAddedContributionEmail(ev: EventSendAddedContributionEmail): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.SEND_ADDED_CONTRIBUTION_EMAIL
return this
}
public setEventSendContributionConfirmEmail(ev: EventSendContributionConfirmEmail): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.SEND_CONTRIBUTION_CONFIRM_EMAIL
return this return this
} }
@ -144,6 +212,13 @@ export class Event {
return this return this
} }
public setEventLogout(ev: EventLogout): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.LOGOUT
return this
}
public setEventRedeemLogin(ev: EventRedeemLogin): Event { public setEventRedeemLogin(ev: EventRedeemLogin): Event {
this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId) this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId)
this.type = EventProtocolType.REDEEM_LOGIN this.type = EventProtocolType.REDEEM_LOGIN
@ -166,44 +241,42 @@ export class Event {
} }
public setEventTransactionSend(ev: EventTransactionSend): Event { public setEventTransactionSend(ev: EventTransactionSend): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount) this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.TRANSACTION_SEND this.type = EventProtocolType.TRANSACTION_SEND
return this return this
} }
public setEventTransactionSendRedeem(ev: EventTransactionSendRedeem): Event { public setEventTransactionSendRedeem(ev: EventTransactionSendRedeem): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount) this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.TRANSACTION_SEND_REDEEM this.type = EventProtocolType.TRANSACTION_SEND_REDEEM
return this return this
} }
public setEventTransactionRepeateRedeem(ev: EventTransactionRepeateRedeem): Event { public setEventTransactionRepeateRedeem(ev: EventTransactionRepeateRedeem): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount) this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.TRANSACTION_REPEATE_REDEEM this.type = EventProtocolType.TRANSACTION_REPEATE_REDEEM
return this return this
} }
public setEventTransactionCreation(ev: EventTransactionCreation): Event { public setEventTransactionCreation(ev: EventTransactionCreation): Event {
this.setByBasicUser(ev.userId) this.setByBasicTx(ev.userId, ev.transactionId, ev.amount)
if (ev.transactionId) this.transactionId = ev.transactionId
if (ev.amount) this.amount = ev.amount
this.type = EventProtocolType.TRANSACTION_CREATION this.type = EventProtocolType.TRANSACTION_CREATION
return this return this
} }
public setEventTransactionReceive(ev: EventTransactionReceive): Event { public setEventTransactionReceive(ev: EventTransactionReceive): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount) this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.TRANSACTION_RECEIVE this.type = EventProtocolType.TRANSACTION_RECEIVE
return this return this
} }
public setEventTransactionReceiveRedeem(ev: EventTransactionReceiveRedeem): Event { public setEventTransactionReceiveRedeem(ev: EventTransactionReceiveRedeem): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount) this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.TRANSACTION_RECEIVE_REDEEM this.type = EventProtocolType.TRANSACTION_RECEIVE_REDEEM
return this return this
@ -216,15 +289,48 @@ export class Event {
return this return this
} }
public setEventContributionConfirm(ev: EventContributionConfirm): Event { public setEventUserCreateContributionMessage(ev: EventUserCreateContributionMessage): Event {
this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId)
this.type = EventProtocolType.USER_CREATE_CONTRIBUTION_MESSAGE
return this
}
public setEventAdminCreateContributionMessage(ev: EventAdminCreateContributionMessage): Event {
this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId)
this.type = EventProtocolType.ADMIN_CREATE_CONTRIBUTION_MESSAGE
return this
}
public setEventContributionDelete(ev: EventContributionDelete): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
if (ev.xUserId) this.xUserId = ev.xUserId this.type = EventProtocolType.CONTRIBUTION_DELETE
if (ev.xCommunityId) this.xCommunityId = ev.xCommunityId
return this
}
public setEventContributionUpdate(ev: EventContributionUpdate): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CONTRIBUTION_UPDATE
return this
}
public setEventContributionConfirm(ev: EventContributionConfirm): Event {
this.setByBasicCtX(ev.userId, ev.contributionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.CONTRIBUTION_CONFIRM this.type = EventProtocolType.CONTRIBUTION_CONFIRM
return this return this
} }
public setEventContributionDeny(ev: EventContributionDeny): Event {
this.setByBasicCtX(ev.userId, ev.contributionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.CONTRIBUTION_DENY
return this
}
public setEventContributionLinkDefine(ev: EventContributionLinkDefine): Event { public setEventContributionLinkDefine(ev: EventContributionLinkDefine): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CONTRIBUTION_LINK_DEFINE this.type = EventProtocolType.CONTRIBUTION_LINK_DEFINE
@ -246,26 +352,58 @@ export class Event {
return this return this
} }
setByBasicTx( setByBasicTx(userId: number, transactionId: number, amount: decimal): Event {
userId: number,
xUserId?: number,
xCommunityId?: number,
transactionId?: number,
amount?: decimal,
): Event {
this.setByBasicUser(userId) this.setByBasicUser(userId)
if (xUserId) this.xUserId = xUserId this.transactionId = transactionId
if (xCommunityId) this.xCommunityId = xCommunityId this.amount = amount
if (transactionId) this.transactionId = transactionId
if (amount) this.amount = amount
return this return this
} }
setByBasicCt(userId: number, contributionId: number, amount?: decimal): Event { setByBasicTxX(
userId: number,
transactionId: number,
amount: decimal,
xUserId: number,
xCommunityId: number,
): Event {
this.setByBasicTx(userId, transactionId, amount)
this.xUserId = xUserId
this.xCommunityId = xCommunityId
return this
}
setByBasicCt(userId: number, contributionId: number, amount: decimal): Event {
this.setByBasicUser(userId) this.setByBasicUser(userId)
if (contributionId) this.contributionId = contributionId this.contributionId = contributionId
if (amount) this.amount = amount this.amount = amount
return this
}
setByBasicCtMsg(
userId: number,
contributionId: number,
amount: decimal,
messageId: number,
): Event {
this.setByBasicCt(userId, contributionId, amount)
this.messageId = messageId
return this
}
setByBasicCtX(
userId: number,
contributionId: number,
amount: decimal,
xUserId: number,
xCommunityId: number,
): Event {
this.setByBasicCt(userId, contributionId, amount)
this.xUserId = xUserId
this.xCommunityId = xCommunityId
return this return this
} }
@ -278,27 +416,6 @@ export class Event {
return this return this
} }
setByEventTransactionCreation(event: EventTransactionCreation): Event {
this.type = event.type
this.createdAt = event.createdAt
this.userId = event.userId
this.transactionId = event.transactionId
this.amount = event.amount
return this
}
setByEventContributionConfirm(event: EventContributionConfirm): Event {
this.type = event.type
this.createdAt = event.createdAt
this.userId = event.userId
this.xUserId = event.xUserId
this.xCommunityId = event.xCommunityId
this.amount = event.amount
return this
}
id: number id: number
type: string type: string
createdAt: Date createdAt: Date
@ -308,4 +425,5 @@ export class Event {
transactionId?: number transactionId?: number
contributionId?: number contributionId?: number
amount?: decimal amount?: decimal
messageId?: number
} }

View File

@ -3,23 +3,36 @@ export enum EventProtocolType {
VISIT_GRADIDO = 'VISIT_GRADIDO', VISIT_GRADIDO = 'VISIT_GRADIDO',
REGISTER = 'REGISTER', REGISTER = 'REGISTER',
REDEEM_REGISTER = 'REDEEM_REGISTER', REDEEM_REGISTER = 'REDEEM_REGISTER',
VERIFY_REDEEM = 'VERIFY_REDEEM',
INACTIVE_ACCOUNT = 'INACTIVE_ACCOUNT', INACTIVE_ACCOUNT = 'INACTIVE_ACCOUNT',
SEND_CONFIRMATION_EMAIL = 'SEND_CONFIRMATION_EMAIL', SEND_CONFIRMATION_EMAIL = 'SEND_CONFIRMATION_EMAIL',
SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL = 'SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL', SEND_ACCOUNT_MULTIREGISTRATION_EMAIL = 'SEND_ACCOUNT_MULTIREGISTRATION_EMAIL',
CONFIRM_EMAIL = 'CONFIRM_EMAIL', CONFIRM_EMAIL = 'CONFIRM_EMAIL',
REGISTER_EMAIL_KLICKTIPP = 'REGISTER_EMAIL_KLICKTIPP', REGISTER_EMAIL_KLICKTIPP = 'REGISTER_EMAIL_KLICKTIPP',
LOGIN = 'LOGIN', LOGIN = 'LOGIN',
LOGOUT = 'LOGOUT',
REDEEM_LOGIN = 'REDEEM_LOGIN', REDEEM_LOGIN = 'REDEEM_LOGIN',
ACTIVATE_ACCOUNT = 'ACTIVATE_ACCOUNT', ACTIVATE_ACCOUNT = 'ACTIVATE_ACCOUNT',
SEND_FORGOT_PASSWORD_EMAIL = 'SEND_FORGOT_PASSWORD_EMAIL',
PASSWORD_CHANGE = 'PASSWORD_CHANGE', PASSWORD_CHANGE = 'PASSWORD_CHANGE',
SEND_TRANSACTION_SEND_EMAIL = 'SEND_TRANSACTION_SEND_EMAIL',
SEND_TRANSACTION_RECEIVE_EMAIL = 'SEND_TRANSACTION_RECEIVE_EMAIL',
TRANSACTION_SEND = 'TRANSACTION_SEND', TRANSACTION_SEND = 'TRANSACTION_SEND',
TRANSACTION_SEND_REDEEM = 'TRANSACTION_SEND_REDEEM', TRANSACTION_SEND_REDEEM = 'TRANSACTION_SEND_REDEEM',
TRANSACTION_REPEATE_REDEEM = 'TRANSACTION_REPEATE_REDEEM', TRANSACTION_REPEATE_REDEEM = 'TRANSACTION_REPEATE_REDEEM',
TRANSACTION_CREATION = 'TRANSACTION_CREATION', TRANSACTION_CREATION = 'TRANSACTION_CREATION',
TRANSACTION_RECEIVE = 'TRANSACTION_RECEIVE', TRANSACTION_RECEIVE = 'TRANSACTION_RECEIVE',
TRANSACTION_RECEIVE_REDEEM = 'TRANSACTION_RECEIVE_REDEEM', TRANSACTION_RECEIVE_REDEEM = 'TRANSACTION_RECEIVE_REDEEM',
SEND_TRANSACTION_LINK_REDEEM_EMAIL = 'SEND_TRANSACTION_LINK_REDEEM_EMAIL',
SEND_ADDED_CONTRIBUTION_EMAIL = 'SEND_ADDED_CONTRIBUTION_EMAIL',
SEND_CONTRIBUTION_CONFIRM_EMAIL = 'SEND_CONTRIBUTION_CONFIRM_EMAIL',
CONTRIBUTION_CREATE = 'CONTRIBUTION_CREATE', CONTRIBUTION_CREATE = 'CONTRIBUTION_CREATE',
CONTRIBUTION_CONFIRM = 'CONTRIBUTION_CONFIRM', CONTRIBUTION_CONFIRM = 'CONTRIBUTION_CONFIRM',
CONTRIBUTION_DENY = 'CONTRIBUTION_DENY',
CONTRIBUTION_LINK_DEFINE = 'CONTRIBUTION_LINK_DEFINE', CONTRIBUTION_LINK_DEFINE = 'CONTRIBUTION_LINK_DEFINE',
CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM', CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM',
CONTRIBUTION_DELETE = 'CONTRIBUTION_DELETE',
CONTRIBUTION_UPDATE = 'CONTRIBUTION_UPDATE',
USER_CREATE_CONTRIBUTION_MESSAGE = 'USER_CREATE_CONTRIBUTION_MESSAGE',
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE',
} }

View File

@ -17,6 +17,7 @@ import {
setUserRole, setUserRole,
deleteUser, deleteUser,
unDeleteUser, unDeleteUser,
createContribution,
adminCreateContribution, adminCreateContribution,
adminCreateContributions, adminCreateContributions,
adminUpdateContribution, adminUpdateContribution,
@ -77,6 +78,7 @@ afterAll(async () => {
let admin: User let admin: User
let user: User let user: User
let creation: Contribution | void let creation: Contribution | void
let result: any
describe('AdminResolver', () => { describe('AdminResolver', () => {
describe('set user role', () => { describe('set user role', () => {
@ -1365,6 +1367,38 @@ describe('AdminResolver', () => {
}) })
}) })
describe('admin deletes own user contribution', () => {
beforeAll(async () => {
await query({
query: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
result = await mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
})
})
it('throws an error', async () => {
await expect(
mutate({
mutation: adminDeleteContribution,
variables: {
id: result.data.createContribution.id,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Own contribution can not be deleted as admin')],
}),
)
})
})
describe('creation id does exist', () => { describe('creation id does exist', () => {
it('returns true', async () => { it('returns true', async () => {
await expect( await expect(

View File

@ -400,13 +400,24 @@ export class AdminResolver {
@Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION]) @Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION])
@Mutation(() => Boolean) @Mutation(() => Boolean)
async adminDeleteContribution(@Arg('id', () => Int) id: number): Promise<boolean> { async adminDeleteContribution(
@Arg('id', () => Int) id: number,
@Ctx() context: Context,
): Promise<boolean> {
const contribution = await DbContribution.findOne(id) const contribution = await DbContribution.findOne(id)
if (!contribution) { if (!contribution) {
logger.error(`Contribution not found for given id: ${id}`) logger.error(`Contribution not found for given id: ${id}`)
throw new Error('Contribution not found for given id.') throw new Error('Contribution not found for given id.')
} }
const moderator = getUser(context)
if (
contribution.contributionType === ContributionType.USER &&
contribution.userId === moderator.id
) {
throw new Error('Own contribution can not be deleted as admin')
}
contribution.contributionStatus = ContributionStatus.DELETED contribution.contributionStatus = ContributionStatus.DELETED
contribution.deletedBy = moderator.id
await contribution.save() await contribution.save()
const res = await contribution.softRemove() const res = await contribution.softRemove()
return !!res return !!res

View File

@ -17,6 +17,9 @@ import { userFactory } from '@/seeds/factory/user'
import { creationFactory } from '@/seeds/factory/creation' import { creationFactory } from '@/seeds/factory/creation'
import { creations } from '@/seeds/creation/index' import { creations } from '@/seeds/creation/index'
import { peterLustig } from '@/seeds/users/peter-lustig' import { peterLustig } from '@/seeds/users/peter-lustig'
import { EventProtocol } from '@entity/EventProtocol'
import { EventProtocolType } from '@/event/EventProtocolType'
import { logger } from '@test/testSetup'
let mutate: any, query: any, con: any let mutate: any, query: any, con: any
let testEnv: any let testEnv: any
@ -36,6 +39,8 @@ afterAll(async () => {
}) })
describe('ContributionResolver', () => { describe('ContributionResolver', () => {
let bibi: any
describe('createContribution', () => { describe('createContribution', () => {
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('returns an error', async () => { it('returns an error', async () => {
@ -55,7 +60,8 @@ describe('ContributionResolver', () => {
describe('authenticated with valid user', () => { describe('authenticated with valid user', () => {
beforeAll(async () => { beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg) await userFactory(testEnv, bibiBloxberg)
await mutate({
bibi = await mutate({
mutation: login, mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
}) })
@ -85,6 +91,10 @@ describe('ContributionResolver', () => {
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`memo text is too short: memo.length=4 < (5)`)
})
it('throws error when memo length greater than 255 chars', async () => { it('throws error when memo length greater than 255 chars', async () => {
const date = new Date() const date = new Date()
await expect( await expect(
@ -103,6 +113,10 @@ describe('ContributionResolver', () => {
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`memo text is too long: memo.length=259 > (255)`)
})
it('throws error when creationDate not-valid', async () => { it('throws error when creationDate not-valid', async () => {
await expect( await expect(
mutate({ mutate({
@ -122,6 +136,13 @@ describe('ContributionResolver', () => {
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
'Invalid Date',
)
})
it('throws error when creationDate 3 month behind', async () => { it('throws error when creationDate 3 month behind', async () => {
const date = new Date() const date = new Date()
await expect( await expect(
@ -141,20 +162,31 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
'Invalid Date',
)
})
}) })
describe('valid input', () => { describe('valid input', () => {
it('creates contribution', async () => { let contribution: any
await expect(
mutate({ beforeAll(async () => {
contribution = await mutate({
mutation: createContribution, mutation: createContribution,
variables: { variables: {
amount: 100.0, amount: 100.0,
memo: 'Test env contribution', memo: 'Test env contribution',
creationDate: new Date().toString(), creationDate: new Date().toString(),
}, },
}), })
).resolves.toEqual( })
it('creates contribution', async () => {
expect(contribution).toEqual(
expect.objectContaining({ expect.objectContaining({
data: { data: {
createContribution: { createContribution: {
@ -166,6 +198,17 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
it('stores the create contribution event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.CONTRIBUTION_CREATE,
amount: expect.decimalEqual(100),
contributionId: contribution.data.createContribution.id,
userId: bibi.data.login.id,
}),
)
})
}) })
}) })
}) })
@ -348,6 +391,10 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('No contribution found to given id')
})
}) })
describe('Memo length smaller than 5 chars', () => { describe('Memo length smaller than 5 chars', () => {
@ -369,6 +416,10 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < (5)')
})
}) })
describe('Memo length greater than 255 chars', () => { describe('Memo length greater than 255 chars', () => {
@ -390,6 +441,10 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('memo text is too long: memo.length=259 > (255)')
})
}) })
describe('wrong user tries to update the contribution', () => { describe('wrong user tries to update the contribution', () => {
@ -421,6 +476,12 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'user of the pending contribution and send user does not correspond',
)
})
}) })
describe('admin tries to update a user contribution', () => { describe('admin tries to update a user contribution', () => {
@ -442,6 +503,8 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
// TODO check that the error is logged (need to modify AdminResolver, avoid conflicts)
}) })
describe('update too much so that the limit is exceeded', () => { describe('update too much so that the limit is exceeded', () => {
@ -473,6 +536,12 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'The amount (1019 GDD) to be created exceeds the amount (1000 GDD) still available for this month.',
)
})
}) })
describe('update creation to a date that is older than 3 months', () => { describe('update creation to a date that is older than 3 months', () => {
@ -494,6 +563,13 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
'Invalid Date',
)
})
}) })
describe('valid input', () => { describe('valid input', () => {
@ -520,6 +596,22 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
it('stores the update contribution event in the database', async () => {
bibi = await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.CONTRIBUTION_UPDATE,
amount: expect.decimalEqual(10),
contributionId: result.data.createContribution.id,
userId: bibi.data.login.id,
}),
)
})
}) })
}) })
}) })
@ -626,9 +718,11 @@ describe('ContributionResolver', () => {
}) })
describe('authenticated', () => { describe('authenticated', () => {
let peter: any
beforeAll(async () => { beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg) await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig) peter = await userFactory(testEnv, peterLustig)
await mutate({ await mutate({
mutation: login, mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
@ -663,9 +757,13 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('Contribution not found for given id')
})
}) })
describe('other user sends a deleteContribtuion', () => { describe('other user sends a deleteContribution', () => {
it('returns an error', async () => { it('returns an error', async () => {
await mutate({ await mutate({
mutation: login, mutation: login,
@ -684,6 +782,10 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('Can not delete contribution of another user')
})
}) })
describe('User deletes own contribution', () => { describe('User deletes own contribution', () => {
@ -697,6 +799,33 @@ describe('ContributionResolver', () => {
}), }),
).resolves.toBeTruthy() ).resolves.toBeTruthy()
}) })
it('stores the delete contribution event in the database', async () => {
const contribution = await mutate({
mutation: createContribution,
variables: {
amount: 166.0,
memo: 'Whatever contribution',
creationDate: new Date().toString(),
},
})
await mutate({
mutation: deleteContribution,
variables: {
id: contribution.data.createContribution.id,
},
})
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.CONTRIBUTION_DELETE,
contributionId: contribution.data.createContribution.id,
amount: expect.decimalEqual(166),
userId: peter.id,
}),
)
})
}) })
describe('User deletes already confirmed contribution', () => { describe('User deletes already confirmed contribution', () => {
@ -728,6 +857,10 @@ describe('ContributionResolver', () => {
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('A confirmed contribution can not be deleted')
})
}) })
}) })
}) })

View File

@ -13,6 +13,13 @@ import { Contribution, ContributionListResult } from '@model/Contribution'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { validateContribution, getUserCreation, updateCreations } from './util/creations' import { validateContribution, getUserCreation, updateCreations } from './util/creations'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
import {
Event,
EventContributionCreate,
EventContributionDelete,
EventContributionUpdate,
} from '@/event/Event'
import { eventProtocol } from '@/event/EventProtocolEmitter'
@Resolver() @Resolver()
export class ContributionResolver { export class ContributionResolver {
@ -23,15 +30,17 @@ export class ContributionResolver {
@Ctx() context: Context, @Ctx() context: Context,
): Promise<UnconfirmedContribution> { ): Promise<UnconfirmedContribution> {
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)`)
} }
const event = new Event()
const user = getUser(context) const user = getUser(context)
const creations = await getUserCreation(user.id) const creations = await getUserCreation(user.id)
logger.trace('creations', creations) logger.trace('creations', creations)
@ -49,6 +58,13 @@ export class ContributionResolver {
logger.trace('contribution to save', contribution) logger.trace('contribution to save', contribution)
await dbContribution.save(contribution) await dbContribution.save(contribution)
const eventCreateContribution = new EventContributionCreate()
eventCreateContribution.userId = user.id
eventCreateContribution.amount = amount
eventCreateContribution.contributionId = contribution.id
await eventProtocol.writeEvent(event.setEventContributionCreate(eventCreateContribution))
return new UnconfirmedContribution(contribution, user, creations) return new UnconfirmedContribution(contribution, user, creations)
} }
@ -58,19 +74,33 @@ export class ContributionResolver {
@Arg('id', () => Int) id: number, @Arg('id', () => Int) id: number,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<boolean> { ): Promise<boolean> {
const event = new Event()
const user = getUser(context) const user = getUser(context)
const contribution = await dbContribution.findOne(id) const contribution = await dbContribution.findOne(id)
if (!contribution) { if (!contribution) {
logger.error('Contribution not found for given id')
throw new Error('Contribution not found for given id.') throw new Error('Contribution not found for given id.')
} }
if (contribution.userId !== user.id) { if (contribution.userId !== user.id) {
logger.error('Can not delete contribution of another user')
throw new Error('Can not delete contribution of another user') throw new Error('Can not delete contribution of another user')
} }
if (contribution.confirmedAt) { if (contribution.confirmedAt) {
logger.error('A confirmed contribution can not be deleted')
throw new Error('A confirmed contribution can not be deleted') throw new Error('A confirmed contribution can not be deleted')
} }
contribution.contributionStatus = ContributionStatus.DELETED contribution.contributionStatus = ContributionStatus.DELETED
contribution.deletedBy = user.id
contribution.deletedAt = new Date()
await contribution.save() await contribution.save()
const eventDeleteContribution = new EventContributionDelete()
eventDeleteContribution.userId = user.id
eventDeleteContribution.contributionId = contribution.id
eventDeleteContribution.amount = contribution.amount
await eventProtocol.writeEvent(event.setEventContributionDelete(eventDeleteContribution))
const res = await contribution.softRemove() const res = await contribution.softRemove()
return !!res return !!res
} }
@ -98,6 +128,7 @@ export class ContributionResolver {
.from(dbContribution, 'c') .from(dbContribution, 'c')
.leftJoinAndSelect('c.messages', 'm') .leftJoinAndSelect('c.messages', 'm')
.where(where) .where(where)
.withDeleted()
.orderBy('c.createdAt', order) .orderBy('c.createdAt', order)
.limit(pageSize) .limit(pageSize)
.offset((currentPage - 1) * pageSize) .offset((currentPage - 1) * pageSize)
@ -154,9 +185,11 @@ export class ContributionResolver {
where: { id: contributionId, confirmedAt: IsNull() }, where: { id: contributionId, confirmedAt: IsNull() },
}) })
if (!contributionToUpdate) { if (!contributionToUpdate) {
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.')
} }
if (contributionToUpdate.userId !== user.id) { if (contributionToUpdate.userId !== user.id) {
logger.error('user of the pending contribution and send user does not correspond')
throw new Error('user of the pending contribution and send user does not correspond') throw new Error('user of the pending contribution and send user does not correspond')
} }
@ -177,6 +210,14 @@ export class ContributionResolver {
contributionToUpdate.contributionStatus = ContributionStatus.PENDING contributionToUpdate.contributionStatus = ContributionStatus.PENDING
dbContribution.save(contributionToUpdate) dbContribution.save(contributionToUpdate)
const event = new Event()
const eventUpdateContribution = new EventContributionUpdate()
eventUpdateContribution.userId = user.id
eventUpdateContribution.contributionId = contributionId
eventUpdateContribution.amount = amount
await eventProtocol.writeEvent(event.setEventContributionUpdate(eventUpdateContribution))
return new UnconfirmedContribution(contributionToUpdate, user, creations) return new UnconfirmedContribution(contributionToUpdate, user, creations)
} }
} }

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

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

@ -6,7 +6,7 @@ import CONFIG from '@/config'
import { Context, getUser } from '@/server/context' import { Context, getUser } from '@/server/context'
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql' import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
import { getCustomRepository, getConnection } from '@dbTools/typeorm' import { getCustomRepository, getConnection, In } from '@dbTools/typeorm'
import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail' import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail'
@ -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}`)
@ -224,11 +249,11 @@ export class TransactionResolver {
logger.debug(`involvedUserIds=${involvedUserIds}`) logger.debug(`involvedUserIds=${involvedUserIds}`)
// We need to show the name for deleted users for old transactions // We need to show the name for deleted users for old transactions
const involvedDbUsers = await dbUser const involvedDbUsers = await dbUser.find({
.createQueryBuilder() where: { id: In(involvedUserIds) },
.withDeleted() withDeleted: true,
.where('id IN (:...userIds)', { userIds: involvedUserIds }) relations: ['emailContact'],
.getMany() })
const involvedUsers = involvedDbUsers.map((u) => new User(u)) const involvedUsers = involvedDbUsers.map((u) => new User(u))
logger.debug(`involvedUsers=${involvedUsers}`) logger.debug(`involvedUsers=${involvedUsers}`)
@ -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

@ -21,7 +21,7 @@ export const validateContribution = (
if (index < 0) { if (index < 0) {
logger.error( logger.error(
'No information for available creations with the given creationDate=', 'No information for available creations with the given creationDate=',
creationDate, creationDate.toString(),
) )
throw new Error('No information for available creations for the given date') throw new Error('No information for available creations for the given date')
} }

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

@ -29,6 +29,7 @@ describe('sendEMail', () => {
let result: boolean let result: boolean
describe('config email is false', () => { describe('config email is false', () => {
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks()
result = await sendEMail({ result = await sendEMail({
to: 'receiver@mail.org', to: 'receiver@mail.org',
cc: 'support@gradido.net', cc: 'support@gradido.net',
@ -48,6 +49,7 @@ describe('sendEMail', () => {
describe('config email is true', () => { describe('config email is true', () => {
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks()
CONFIG.EMAIL = true CONFIG.EMAIL = true
result = await sendEMail({ result = await sendEMail({
to: 'receiver@mail.org', to: 'receiver@mail.org',
@ -73,7 +75,7 @@ describe('sendEMail', () => {
it('calls sendMail of transporter', () => { it('calls sendMail of transporter', () => {
expect((createTransport as jest.Mock).mock.results[0].value.sendMail).toBeCalledWith({ expect((createTransport as jest.Mock).mock.results[0].value.sendMail).toBeCalledWith({
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`, from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
to: `${CONFIG.EMAIL_TEST_RECEIVER}`, to: 'receiver@mail.org',
cc: 'support@gradido.net', cc: 'support@gradido.net',
subject: 'Subject', subject: 'Subject',
text: 'Text text text', text: 'Text text text',
@ -84,4 +86,28 @@ describe('sendEMail', () => {
expect(result).toBeTruthy() expect(result).toBeTruthy()
}) })
}) })
describe('with email EMAIL_TEST_MODUS true', () => {
beforeEach(async () => {
jest.clearAllMocks()
CONFIG.EMAIL = true
CONFIG.EMAIL_TEST_MODUS = true
result = await sendEMail({
to: 'receiver@mail.org',
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
})
})
it('calls sendMail of transporter with faked to', () => {
expect((createTransport as jest.Mock).mock.results[0].value.sendMail).toBeCalledWith({
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
to: CONFIG.EMAIL_TEST_RECEIVER,
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
})
})
})
}) })

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

@ -0,0 +1,42 @@
import Decimal from 'decimal.js-light'
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
@Entity('event_protocol')
export class EventProtocol extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ length: 100, nullable: false, collation: 'utf8mb4_unicode_ci' })
type: string
@Column({ name: 'created_at', type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date
@Column({ name: 'user_id', unsigned: true, nullable: false })
userId: number
@Column({ name: 'x_user_id', unsigned: true, nullable: true })
xUserId: number
@Column({ name: 'x_community_id', unsigned: true, nullable: true })
xCommunityId: number
@Column({ name: 'transaction_id', unsigned: true, nullable: true })
transactionId: number
@Column({ name: 'contribution_id', unsigned: true, nullable: true })
contributionId: number
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: true,
transformer: DecimalTransformer,
})
amount: Decimal
@Column({ name: 'message_id', unsigned: true, nullable: true })
messageId: number
}

View File

@ -0,0 +1,92 @@
import Decimal from 'decimal.js-light'
import {
BaseEntity,
Column,
Entity,
PrimaryGeneratedColumn,
DeleteDateColumn,
JoinColumn,
ManyToOne,
OneToMany,
} from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
import { User } from '../User'
import { ContributionMessage } from '../ContributionMessage'
@Entity('contributions')
export class Contribution extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@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
@Column({ type: 'datetime', nullable: false, name: 'contribution_date' })
contributionDate: Date
@Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
amount: Decimal
@Column({ unsigned: true, nullable: true, name: 'moderator_id' })
moderatorId: number
@Column({ unsigned: true, nullable: true, name: 'contribution_link_id' })
contributionLinkId: number
@Column({ unsigned: true, nullable: true, name: 'confirmed_by' })
confirmedBy: number
@Column({ nullable: true, name: 'confirmed_at' })
confirmedAt: Date
@Column({ unsigned: true, nullable: true, name: 'denied_by' })
deniedBy: number
@Column({ nullable: true, name: 'denied_at' })
deniedAt: Date
@Column({
name: 'contribution_type',
length: 12,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
contributionType: string
@Column({
name: 'contribution_status',
length: 12,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
contributionStatus: string
@Column({ unsigned: true, nullable: true, name: 'transaction_id' })
transactionId: number
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt: Date | null
@DeleteDateColumn({ unsigned: true, nullable: true, name: 'deleted_by' })
deletedBy: number
@OneToMany(() => ContributionMessage, (message) => message.contribution)
@JoinColumn({ name: 'contribution_id' })
messages?: ContributionMessage[]
}

View File

@ -1 +1 @@
export { Contribution } from './0047-messages_tables/Contribution' export { Contribution } from './0051-add_delete_by_to_contributions/Contribution'

View File

@ -1 +1 @@
export { EventProtocol } from './0043-add_event_protocol_table/EventProtocol' export { EventProtocol } from './0050-add_messageId_to_event_protocol/EventProtocol'

View File

@ -0,0 +1,12 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
`ALTER TABLE \`event_protocol\` ADD COLUMN \`message_id\` int(10) unsigned NULL DEFAULT NULL;`,
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`ALTER TABLE \`event_protocol\` DROP COLUMN \`message_id\`;`)
}

View File

@ -0,0 +1,12 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
`ALTER TABLE \`contributions\` ADD COLUMN \`deleted_by\` int(10) unsigned DEFAULT NULL;`,
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`ALTER TABLE \`contributions\` DROP COLUMN \`deleted_by\`;`)
}

View File

@ -1,6 +1,6 @@
{ {
"name": "gradido-database", "name": "gradido-database",
"version": "1.12.1", "version": "1.13.1",
"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.12.1", "version": "1.13.1",
"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'" :to="text">{{ 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

@ -37,6 +37,7 @@ export const transactionsQuery = gql`
linkedUser { linkedUser {
firstName firstName
lastName lastName
email
} }
decay { decay {
decay decay
@ -44,9 +45,6 @@ export const transactionsQuery = gql`
end end
duration duration
} }
linkedUser {
email
}
transactionLinkId transactionLinkId
} }
} }

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.12.1", "version": "1.13.1",
"description": "Gradido", "description": "Gradido",
"main": "index.js", "main": "index.js",
"repository": "git@github.com:gradido/gradido.git", "repository": "git@github.com:gradido/gradido.git",