Merge remote-tracking branch 'origin/master' into

1574-Concept_to_introduce_Gradido-ID
This commit is contained in:
Claus-Peter Hübner 2022-10-06 01:20:43 +02:00
commit f268651700
190 changed files with 11026 additions and 1472 deletions

View File

@ -4,8 +4,56 @@ 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.12.1](https://github.com/gradido/gradido/compare/1.12.0...1.12.1)
- 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)
> 12 September 2022
- release: v1.12.0 [`#2191`](https://github.com/gradido/gradido/pull/2191)
- if message empty else disabled button [`#2189`](https://github.com/gradido/gradido/pull/2189)
- messages show if Confirmed [`#2185`](https://github.com/gradido/gradido/pull/2185)
- text in messages smaller [`#2186`](https://github.com/gradido/gradido/pull/2186)
- feat: 🍰 Klicktipp retrieve not registered email [`#2181`](https://github.com/gradido/gradido/pull/2181)
- fix: 🍰 isModerator on messages to switch the messages side in the messages overview [`#2182`](https://github.com/gradido/gradido/pull/2182)
- Refactor locales for Nederlands [`#2174`](https://github.com/gradido/gradido/pull/2174)
- Add is moderator to contribution message [`#2180`](https://github.com/gradido/gradido/pull/2180)
- feat: 🍰 Moderator Cannot Answer Himself [`#2178`](https://github.com/gradido/gradido/pull/2178)
- refactor: Improve Statistics Query [`#2170`](https://github.com/gradido/gradido/pull/2170)
- fix: Remove Statistics from Wallet [`#2171`](https://github.com/gradido/gradido/pull/2171)
- feat: 🍰 Contribution Messages In Frontend [`#2164`](https://github.com/gradido/gradido/pull/2164)
- feat: 🚀 CRUD For Contribution Messages [`#2149`](https://github.com/gradido/gradido/pull/2149)
- fix: 🍰 Decay Calculation In Community Statistics [`#2167`](https://github.com/gradido/gradido/pull/2167)
- chore: 🍰 Remove Fetch Policy Network Only From Statistics [`#2159`](https://github.com/gradido/gradido/pull/2159)
- feat: 🍰 Remove Some Statistics Data From Frontend [`#2153`](https://github.com/gradido/gradido/pull/2153)
- feat: 🍰 Add Toogle Collaps On Language Name [`#2156`](https://github.com/gradido/gradido/pull/2156)
- 2145 corrections style for frontend [`#2147`](https://github.com/gradido/gradido/pull/2147)
- 2072 feature usecase contribution messaging [`#2073`](https://github.com/gradido/gradido/pull/2073)
- 2151 add hint to redeem link [`#2158`](https://github.com/gradido/gradido/pull/2158)
- 🍰 Create `contribution messages` table [`#2137`](https://github.com/gradido/gradido/pull/2137)
- feat: 🍰 Add The Languages French And Dutch [`#2138`](https://github.com/gradido/gradido/pull/2138)
- 1973 list open contribution links in the wallet [`#1975`](https://github.com/gradido/gradido/pull/1975)
- feat: 🍰 Admin Interface Displays Statistics [`#2124`](https://github.com/gradido/gradido/pull/2124)
- feat: Statistics Resolver [`#2041`](https://github.com/gradido/gradido/pull/2041)
- 2116 retrieve admin and moderators [`#2127`](https://github.com/gradido/gradido/pull/2127)
- 2125 feature gradido id: new column gradidoid in users table [`#2126`](https://github.com/gradido/gradido/pull/2126)
- 2119 new menu item gdt [`#2120`](https://github.com/gradido/gradido/pull/2120)
- feat: Migrate Contributions Table [`#2136`](https://github.com/gradido/gradido/pull/2136)
- chore: 🍰 Refactor Contribution Form Logic And Write Tests [`#2092`](https://github.com/gradido/gradido/pull/2092)
- fix: 🍰 Add `emailChecked` Before Changing `optIn` State & Log Error On klicktipp Middleware [`#2107`](https://github.com/gradido/gradido/pull/2107)
- Add RIGHTS.LIST_CONTRIBUTION_LINKS to ROLE_USER [`#2123`](https://github.com/gradido/gradido/pull/2123)
- 2121 translate locales to spanish [`#2122`](https://github.com/gradido/gradido/pull/2122)
- add formatter on input amount replace point and comma [`#2115`](https://github.com/gradido/gradido/pull/2115)
- remove required from form.memo [`#2114`](https://github.com/gradido/gradido/pull/2114)
- Fix pagination ellipsis [`#2104`](https://github.com/gradido/gradido/pull/2104)
#### [1.11.0](https://github.com/gradido/gradido/compare/1.10.1...1.11.0) #### [1.11.0](https://github.com/gradido/gradido/compare/1.10.1...1.11.0)
> 28 July 2022
- release: Version 1.11.0 [`#2103`](https://github.com/gradido/gradido/pull/2103)
- Fix navbar community item [`#2102`](https://github.com/gradido/gradido/pull/2102) - Fix navbar community item [`#2102`](https://github.com/gradido/gradido/pull/2102)
- Add validation date info to copied text after transaction link creation [`#2101`](https://github.com/gradido/gradido/pull/2101) - Add validation date info to copied text after transaction link creation [`#2101`](https://github.com/gradido/gradido/pull/2101)
- Remove member area from menu (desktop and mobile), when user has no elopage account [`#2099`](https://github.com/gradido/gradido/pull/2099) - Remove member area from menu (desktop and mobile), when user has no elopage account [`#2099`](https://github.com/gradido/gradido/pull/2099)

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.11.0", "version": "1.12.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": false, "private": false,
"scripts": { "scripts": {

View File

@ -15,7 +15,10 @@
<b-collapse v-model="visible" id="newContribution" class="mt-2"> <b-collapse v-model="visible" id="newContribution" class="mt-2">
<b-card> <b-card>
<p class="h2 ml-5">{{ $t('contributionLink.contributionLinks') }}</p> <p class="h2 ml-5">{{ $t('contributionLink.contributionLinks') }}</p>
<contribution-link-form :contributionLinkData="contributionLinkData" /> <contribution-link-form
:contributionLinkData="contributionLinkData"
@get-contribution-links="$emit('get-contribution-links')"
/>
</b-card> </b-card>
</b-collapse> </b-collapse>
@ -24,6 +27,7 @@
v-if="count > 0" v-if="count > 0"
:items="items" :items="items"
@editContributionLinkData="editContributionLinkData" @editContributionLinkData="editContributionLinkData"
@get-contribution-links="$emit('get-contribution-links')"
/> />
<div v-else>{{ $t('contributionLink.noContributionLinks') }}</div> <div v-else>{{ $t('contributionLink.noContributionLinks') }}</div>
</b-card-text> </b-card-text>

View File

@ -163,7 +163,6 @@ export default {
if (this.form.validFrom === null) if (this.form.validFrom === null)
return this.toastError(this.$t('contributionLink.noStartDate')) return this.toastError(this.$t('contributionLink.noStartDate'))
if (this.form.validTo === null) return this.toastError(this.$t('contributionLink.noEndDate')) if (this.form.validTo === null) return this.toastError(this.$t('contributionLink.noEndDate'))
// alert(JSON.stringify(this.form))
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: createContributionLink, mutation: createContributionLink,
@ -182,6 +181,8 @@ export default {
this.link = result.data.createContributionLink.link this.link = result.data.createContributionLink.link
this.toastSuccess(this.link) this.toastSuccess(this.link)
this.onReset() this.onReset()
this.$root.$emit('bv::toggle::collapse', 'newContribution')
this.$emit('get-contribution-links')
}) })
.catch((error) => { .catch((error) => {
this.toastError(error.message) this.toastError(error.message)

View File

@ -95,7 +95,7 @@ describe('ContributionLinkList', () => {
}) })
it('toasts a success message', () => { it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('TODO: request message deleted ') expect(toastSuccessSpy).toBeCalledWith('contributionLink.deleted')
}) })
}) })

View File

@ -1,12 +1,12 @@
<template> <template>
<div class="contribution-link-list"> <div class="contribution-link-list">
<b-table striped hover :items="items" :fields="fields"> <b-table striped hover :items="items" :fields="fields">
<template #cell(delete)> <template #cell(delete)="data">
<b-button <b-button
variant="danger" variant="danger"
size="md" size="md"
class="mr-2 test-delete-link" class="mr-2 test-delete-link"
@click="deleteContributionLink" @click="deleteContributionLink(data.item.id, data.item.name)"
> >
<b-icon icon="trash" variant="light"></b-icon> <b-icon icon="trash" variant="light"></b-icon>
</b-button> </b-button>
@ -34,7 +34,7 @@
<h6 class="mb-0">{{ modalData ? modalData.name : '' }}</h6> <h6 class="mb-0">{{ modalData ? modalData.name : '' }}</h6>
</template> </template>
<b-card-text> <b-card-text>
{{ modalData }} {{ modalData.memo ? modalData.memo : '' }}
<figure-qr-code :link="modalData ? modalData.link : ''" /> <figure-qr-code :link="modalData ? modalData.link : ''" />
</b-card-text> </b-card-text>
<template #footer> <template #footer>
@ -70,28 +70,30 @@ export default {
'edit', 'edit',
'show', 'show',
], ],
modalData: null, modalData: {},
modalDataLink: null,
} }
}, },
methods: { methods: {
deleteContributionLink() { deleteContributionLink(id, name) {
this.$bvModal.msgBoxConfirm(this.$t('contributionLink.deleteNow')).then(async (value) => { this.$bvModal
if (value) .msgBoxConfirm(this.$t('contributionLink.deleteNow', { name: name }))
await this.$apollo .then(async (value) => {
.mutate({ if (value)
mutation: deleteContributionLink, await this.$apollo
variables: { .mutate({
id: this.id, mutation: deleteContributionLink,
}, variables: {
}) id: id,
.then(() => { },
this.toastSuccess('TODO: request message deleted ') })
}) .then(() => {
.catch((err) => { this.toastSuccess(this.$t('contributionLink.deleted'))
this.toastError(err.message) this.$emit('get-contribution-links')
}) })
}) .catch((err) => {
this.toastError(err.message)
})
})
}, },
editContributionLink(row) { editContributionLink(row) {
this.$emit('editContributionLinkData', row) this.$emit('editContributionLinkData', row)

View File

@ -0,0 +1,111 @@
import { mount } from '@vue/test-utils'
import ContributionMessagesFormular from './ContributionMessagesFormular.vue'
import { toastErrorSpy, toastSuccessSpy } from '../../../test/testSetup'
const localVue = global.localVue
const apolloMutateMock = jest.fn().mockResolvedValue()
describe('ContributionMessagesFormular', () => {
let wrapper
const propsData = {
contributionId: 42,
}
const mocks = {
$t: jest.fn((t) => t),
$apollo: {
mutate: apolloMutateMock,
},
$i18n: {
locale: 'en',
},
}
const Wrapper = () => {
return mount(ContributionMessagesFormular, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV .contribution-messages-formular', () => {
expect(wrapper.find('div.contribution-messages-formular').exists()).toBe(true)
})
describe('on trigger reset', () => {
beforeEach(async () => {
wrapper.setData({
form: {
text: 'text form message',
},
})
await wrapper.find('form').trigger('reset')
})
it('form has empty text', () => {
expect(wrapper.vm.form).toEqual({
text: '',
})
})
})
describe('on trigger submit', () => {
beforeEach(async () => {
wrapper.setData({
form: {
text: 'text form message',
},
})
await wrapper.find('form').trigger('submit')
})
it('emitted "get-list-contribution-messages" with data', async () => {
expect(wrapper.emitted('get-list-contribution-messages')).toEqual(
expect.arrayContaining([expect.arrayContaining([42])]),
)
})
it('emitted "update-state" with data', async () => {
expect(wrapper.emitted('update-state')).toEqual(
expect.arrayContaining([expect.arrayContaining([42])]),
)
})
})
describe('send contribution message with error', () => {
beforeEach(async () => {
apolloMutateMock.mockRejectedValue({ message: 'OUCH!' })
wrapper = Wrapper()
await wrapper.find('form').trigger('submit')
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('OUCH!')
})
})
describe('send contribution message with success', () => {
beforeEach(async () => {
wrapper.setData({
form: {
text: 'text form message',
},
})
wrapper = Wrapper()
await wrapper.find('form').trigger('submit')
})
it('toasts an success message', () => {
expect(toastSuccessSpy).toBeCalledWith('message.request')
})
})
})
})

View File

@ -0,0 +1,76 @@
<template>
<div class="contribution-messages-formular">
<div class="mt-5">
<b-form @submit.prevent="onSubmit" @reset.prevent="onReset">
<b-form-textarea
id="textarea"
v-model="form.text"
:placeholder="$t('contributionLink.memo')"
rows="3"
></b-form-textarea>
<b-row class="mt-4 mb-6">
<b-col>
<b-button type="reset" variant="danger">{{ $t('form.cancel') }}</b-button>
</b-col>
<b-col class="text-right">
<b-button type="submit" variant="primary" :disabled="disabled">
{{ $t('form.submit') }}
</b-button>
</b-col>
</b-row>
</b-form>
</div>
</div>
</template>
<script>
import { adminCreateContributionMessage } from '@/graphql/adminCreateContributionMessage'
export default {
name: 'ContributionMessagesFormular',
props: {
contributionId: {
type: Number,
required: true,
},
},
data() {
return {
form: {
text: '',
},
}
},
methods: {
onSubmit(event) {
this.$apollo
.mutate({
mutation: adminCreateContributionMessage,
variables: {
contributionId: this.contributionId,
message: this.form.text,
},
})
.then((result) => {
this.$emit('get-list-contribution-messages', this.contributionId)
this.$emit('update-state', this.contributionId)
this.form.text = ''
this.toastSuccess(this.$t('message.request'))
})
.catch((error) => {
this.toastError(error.message)
})
},
onReset(event) {
this.form.text = ''
},
},
computed: {
disabled() {
if (this.form.text !== '') {
return false
}
return true
},
},
}
</script>

View File

@ -0,0 +1,56 @@
import { mount } from '@vue/test-utils'
import ContributionMessagesList from './ContributionMessagesList.vue'
const localVue = global.localVue
const apolloQueryMock = jest.fn().mockResolvedValue()
describe('ContributionMessagesList', () => {
let wrapper
const propsData = {
contributionId: 42,
}
const mocks = {
$t: jest.fn((t) => t),
$i18n: {
locale: 'en',
},
$apollo: {
query: apolloQueryMock,
},
}
const Wrapper = () => {
return mount(ContributionMessagesList, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('sends query to Apollo when created', () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
variables: {
contributionId: propsData.contributionId,
},
}),
)
})
it('has a DIV .contribution-messages-list', () => {
expect(wrapper.find('div.contribution-messages-list').exists()).toBe(true)
})
it('has a Component ContributionMessagesFormular', () => {
expect(wrapper.findComponent({ name: 'ContributionMessagesFormular' }).exists()).toBe(true)
})
})
})

View File

@ -0,0 +1,69 @@
<template>
<div class="contribution-messages-list">
<b-container>
{{ messages.lenght }}
<div v-for="message in messages" v-bind:key="message.id">
<contribution-messages-list-item :message="message" />
</div>
</b-container>
<contribution-messages-formular
:contributionId="contributionId"
@get-list-contribution-messages="getListContributionMessages"
@update-state="updateState"
/>
</div>
</template>
<script>
import ContributionMessagesListItem from './slots/ContributionMessagesListItem.vue'
import ContributionMessagesFormular from '../ContributionMessages/ContributionMessagesFormular.vue'
import { listContributionMessages } from '../../graphql/listContributionMessages.js'
export default {
name: 'ContributionMessagesList',
components: {
ContributionMessagesListItem,
ContributionMessagesFormular,
},
props: {
contributionId: {
type: Number,
required: true,
},
},
data() {
return {
messages: [],
}
},
methods: {
getListContributionMessages(id) {
this.$apollo
.query({
query: listContributionMessages,
variables: {
contributionId: id,
},
fetchPolicy: 'no-cache',
})
.then((result) => {
this.messages = result.data.listContributionMessages.messages
})
.catch((error) => {
this.toastError(error.message)
})
},
updateState(id) {
this.$emit('update-state', id)
},
},
created() {
this.getListContributionMessages(this.contributionId)
},
}
</script>
<style scoped>
.temp-message {
margin-top: 50px;
}
</style>

View File

@ -0,0 +1,58 @@
import { mount } from '@vue/test-utils'
import ContributionMessagesListItem from './ContributionMessagesListItem.vue'
const localVue = global.localVue
describe('ContributionMessagesListItem', () => {
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$store: {
state: {
moderator: {
id: 107,
},
},
},
}
const propsData = {
contributionId: 42,
state: 'PENDING',
message: {
id: 111,
message: 'asd asda sda sda',
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const Wrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV .contribution-messages-list-item', () => {
expect(wrapper.find('div.contribution-messages-list-item').exists()).toBe(true)
})
it('props.message.default', () => {
expect(wrapper.vm.$options.props.message.default.call()).toEqual({})
})
})
})

View File

@ -0,0 +1,27 @@
<template>
<div class="contribution-messages-list-item">
<is-moderator v-if="message.isModerator" :message="message"></is-moderator>
<is-not-moderator v-else :message="message"></is-not-moderator>
</div>
</template>
<script>
import IsModerator from '@/components/ContributionMessages/slots/IsModerator.vue'
import IsNotModerator from '@/components/ContributionMessages/slots/IsNotModerator.vue'
export default {
name: 'ContributionMessagesListItem',
components: {
IsModerator,
IsNotModerator,
},
props: {
message: {
type: Object,
required: true,
default() {
return {}
},
},
},
}
</script>

View File

@ -0,0 +1,49 @@
import { mount } from '@vue/test-utils'
import IsModerator from './IsModerator.vue'
const localVue = global.localVue
describe('IsModerator', () => {
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
}
const propsData = {
message: {
id: 111,
message: 'asd asda sda sda',
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const Wrapper = () => {
return mount(IsModerator, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV .slot-is-moderator', () => {
expect(wrapper.find('div.slot-is-moderator').exists()).toBe(true)
})
it('props.message.default', () => {
expect(wrapper.vm.$options.props.message.default.call()).toEqual({})
})
})
})

View File

@ -0,0 +1,37 @@
<template>
<div class="slot-is-moderator">
<div class="text-right">
<b-avatar square :text="initialLetters" variant="warning"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<small class="ml-4 text-success">{{ $t('moderator') }}</small>
<div class="mt-2">{{ message.message }}</div>
</div>
</div>
</template>
<script>
export default {
props: {
message: {
type: Object,
default() {
return {}
},
},
},
computed: {
initialLetters() {
return `${this.message.userFirstName[0]} ${this.message.userLastName[0]}`
},
},
}
</script>
<style>
.slot-is-moderator {
clear: both;
float: right;
width: 75%;
margin-top: 20px;
margin-bottom: 20px;
}
</style>

View File

@ -0,0 +1,49 @@
import { mount } from '@vue/test-utils'
import IsNotModerator from './IsNotModerator.vue'
const localVue = global.localVue
describe('IsNotModerator', () => {
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
}
const propsData = {
message: {
id: 113,
message: 'asda sdad ad asdasd ',
createdAt: '2022-08-29T12:25:34.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Bibi',
userLastName: 'Bloxberg',
userId: 108,
__typename: 'ContributionMessage',
},
}
const Wrapper = () => {
return mount(IsNotModerator, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV .slot-is-not-moderator', () => {
expect(wrapper.find('div.slot-is-not-moderator').exists()).toBe(true)
})
it('props.message.default', () => {
expect(wrapper.vm.$options.props.message.default.call()).toEqual({})
})
})
})

View File

@ -0,0 +1,34 @@
<template>
<div class="slot-is-not-moderator">
<div>
<b-avatar :text="initialLetters" variant="info"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<div class="mt-2">{{ message.message }}</div>
</div>
</div>
</template>
<script>
export default {
props: {
message: {
type: Object,
default() {
return {}
},
},
},
computed: {
initialLetters() {
return `${this.message.userFirstName[0]} ${this.message.userLastName[0]}`
},
},
}
</script>
<style>
.slot-is-not-moderator {
clear: both;
width: 75%;
margin-top: 20px;
}
</style>

View File

@ -6,30 +6,29 @@ const localVue = global.localVue
const apolloQueryMock = jest.fn().mockResolvedValue({ const apolloQueryMock = jest.fn().mockResolvedValue({
data: { data: {
creationTransactionList: [ creationTransactionList: {
{ contributionCount: 2,
id: 1, contributionList: [
amount: 100, {
balanceDate: 0, id: 1,
creationDate: new Date(), amount: 5.8,
memo: 'Testing', createdAt: '2022-09-21T11:09:51.000Z',
linkedUser: { confirmedAt: null,
firstName: 'Gradido', contributionDate: '2022-08-01T00:00:00.000Z',
lastName: 'Akademie', memo: 'für deine Hilfe, Fräulein Rottenmeier',
state: 'PENDING',
}, },
}, {
{ id: 2,
id: 2, amount: '47',
amount: 200, createdAt: '2022-09-21T11:09:28.000Z',
balanceDate: 0, confirmedAt: '2022-09-21T11:09:28.000Z',
creationDate: new Date(), contributionDate: '2022-08-01T00:00:00.000Z',
memo: 'Testing 2', memo: 'für deine Hilfe, Frau Holle',
linkedUser: { state: 'CONFIRMED',
firstName: 'Gradido',
lastName: 'Akademie',
}, },
}, ],
], },
}, },
}) })
@ -43,7 +42,7 @@ const mocks = {
const propsData = { const propsData = {
userId: 1, userId: 1,
fields: ['date', 'balance', 'name', 'memo', 'decay'], fields: ['createdAt', 'contributionDate', 'confirmedAt', 'amount', 'memo'],
} }
describe('CreationTransactionList', () => { describe('CreationTransactionList', () => {
@ -63,7 +62,7 @@ describe('CreationTransactionList', () => {
expect.objectContaining({ expect.objectContaining({
variables: { variables: {
currentPage: 1, currentPage: 1,
pageSize: 25, pageSize: 10,
order: 'DESC', order: 'DESC',
userId: 1, userId: 1,
}, },

View File

@ -1,7 +1,44 @@
<template> <template>
<div class="component-creation-transaction-list"> <div class="component-creation-transaction-list">
<div class="h3">{{ $t('transactionlist.title') }}</div> <div class="h3">{{ $t('transactionlist.title') }}</div>
<b-table striped hover :fields="fields" :items="items"></b-table> <b-table striped hover :fields="fields" :items="items">
<template #cell(contributionDate)="data">
<div class="font-weight-bold">
{{ $d(new Date(data.item.contributionDate), 'month') }}
</div>
<div>{{ $d(new Date(data.item.contributionDate)) }}</div>
</template>
</b-table>
<div>
<b-pagination
pills
size="lg"
v-model="currentPage"
:per-page="perPage"
:total-rows="rows"
align="center"
:hide-ellipsis="true"
></b-pagination>
<b-button v-b-toggle.collapse-1 variant="light" size="sm">{{ $t('help.help') }}</b-button>
<b-collapse id="collapse-1" class="mt-2">
<div>
{{ $t('transactionlist.submitted') }} {{ $t('math.equals') }}
{{ $t('help.transactionlist.submitted') }}
</div>
<div>
{{ $t('transactionlist.period') }} {{ $t('math.equals') }}
{{ $t('help.transactionlist.periods') }}
</div>
<div>
{{ $t('transactionlist.confirmed') }} {{ $t('math.equals') }}
{{ $t('help.transactionlist.confirmed') }}
</div>
<div>
{{ $t('transactionlist.state') }} {{ $t('math.equals') }}
{{ $t('help.transactionlist.state') }}
</div>
</b-collapse>
</div>
</div> </div>
</template> </template>
<script> <script>
@ -13,14 +50,37 @@ export default {
}, },
data() { data() {
return { return {
items: [],
rows: 0,
currentPage: 1,
perPage: 10,
fields: [ fields: [
{ {
key: 'creationDate', key: 'createdAt',
label: this.$t('transactionlist.date'), label: this.$t('transactionlist.submitted'),
formatter: (value, key, item) => { formatter: (value, key, item) => {
return this.$d(new Date(value)) return this.$d(new Date(value))
}, },
}, },
{
key: 'contributionDate',
label: this.$t('transactionlist.period'),
},
{
key: 'confirmedAt',
label: this.$t('transactionlist.confirmed'),
formatter: (value, key, item) => {
if (value) {
return this.$d(new Date(value))
} else {
return null
}
},
},
{
key: 'state',
label: this.$t('transactionlist.state'),
},
{ {
key: 'amount', key: 'amount',
label: this.$t('transactionlist.amount'), label: this.$t('transactionlist.amount'),
@ -28,23 +88,8 @@ export default {
return `${value} GDD` return `${value} GDD`
}, },
}, },
{
key: 'linkedUser',
label: this.$t('transactionlist.community'),
formatter: (value, key, item) => {
return `${value.firstName} ${value.lastName}`
},
},
{ key: 'memo', label: this.$t('transactionlist.memo') }, { key: 'memo', label: this.$t('transactionlist.memo') },
{
key: 'balanceDate',
label: this.$t('transactionlist.balanceDate'),
formatter: (value, key, item) => {
return this.$d(new Date(value))
},
},
], ],
items: [],
} }
}, },
methods: { methods: {
@ -53,14 +98,15 @@ export default {
.query({ .query({
query: creationTransactionList, query: creationTransactionList,
variables: { variables: {
currentPage: 1, currentPage: this.currentPage,
pageSize: 25, pageSize: this.perPage,
order: 'DESC', order: 'DESC',
userId: parseInt(this.userId), userId: parseInt(this.userId),
}, },
}) })
.then((result) => { .then((result) => {
this.items = result.data.creationTransactionList this.rows = result.data.creationTransactionList.contributionCount
this.items = result.data.creationTransactionList.contributionList
}) })
.catch((error) => { .catch((error) => {
this.toastError(error.message) this.toastError(error.message)
@ -70,5 +116,10 @@ export default {
created() { created() {
this.getTransactions() this.getTransactions()
}, },
watch: {
currentPage() {
this.getTransactions()
},
},
} }
</script> </script>

View File

@ -12,20 +12,42 @@
</b-button> </b-button>
</template> </template>
<template #cell(editCreation)="row"> <template #cell(editCreation)="row">
<b-button <div v-if="$store.state.moderator.id !== row.item.userId">
v-if="row.item.moderator" <b-button
variant="info" v-if="row.item.moderator"
size="md" variant="info"
@click="rowToggleDetails(row, 0)" size="md"
class="mr-2" @click="rowToggleDetails(row, 0)"
> class="mr-2"
<b-icon :icon="row.detailsShowing ? 'x' : 'pencil-square'" aria-label="Help"></b-icon> >
</b-button> <b-icon :icon="row.detailsShowing ? 'x' : 'pencil-square'" aria-label="Help"></b-icon>
</b-button>
<b-button v-else @click="rowToggleDetails(row, 0)">
<b-icon icon="chat-dots"></b-icon>
<b-icon
v-if="row.item.state === 'PENDING' && row.item.messageCount > 0"
icon="exclamation-circle-fill"
variant="warning"
></b-icon>
<b-icon
v-if="row.item.state === 'IN_PROGRESS' && row.item.messageCount > 0"
icon="question-diamond"
variant="light"
></b-icon>
</b-button>
</div>
</template> </template>
<template #cell(confirm)="row"> <template #cell(confirm)="row">
<b-button variant="success" size="md" @click="$emit('show-overlay', row.item)" class="mr-2"> <div v-if="$store.state.moderator.id !== row.item.userId">
<b-icon icon="check" scale="2" variant=""></b-icon> <b-button
</b-button> variant="success"
size="md"
@click="$emit('show-overlay', row.item)"
class="mr-2"
>
<b-icon icon="check" scale="2" variant=""></b-icon>
</b-button>
</div>
</template> </template>
<template #row-details="row"> <template #row-details="row">
<row-details <row-details
@ -33,10 +55,10 @@
type="show-creation" type="show-creation"
slotName="show-creation" slotName="show-creation"
:index="0" :index="0"
@row-toggle-details="rowToggleDetails" @row-toggle-details="rowToggleDetails(row, 0)"
> >
<template #show-creation> <template #show-creation>
<div> <div v-if="row.item.moderator">
<edit-creation-formular <edit-creation-formular
type="singleCreation" type="singleCreation"
:creation="row.item.creation" :creation="row.item.creation"
@ -44,6 +66,12 @@
:row="row" :row="row"
:creationUserData="creationUserData" :creationUserData="creationUserData"
@update-creation-data="updateCreationData" @update-creation-data="updateCreationData"
/>
</div>
<div v-else>
<contribution-messages-list
:contributionId="row.item.id"
@update-state="updateState"
@update-user-data="updateUserData" @update-user-data="updateUserData"
/> />
</div> </div>
@ -58,6 +86,7 @@
import { toggleRowDetails } from '../../mixins/toggleRowDetails' import { toggleRowDetails } from '../../mixins/toggleRowDetails'
import RowDetails from '../RowDetails.vue' import RowDetails from '../RowDetails.vue'
import EditCreationFormular from '../EditCreationFormular.vue' import EditCreationFormular from '../EditCreationFormular.vue'
import ContributionMessagesList from '../ContributionMessages/ContributionMessagesList.vue'
export default { export default {
name: 'OpenCreationsTable', name: 'OpenCreationsTable',
@ -65,6 +94,7 @@ export default {
components: { components: {
EditCreationFormular, EditCreationFormular,
RowDetails, RowDetails,
ContributionMessagesList,
}, },
props: { props: {
items: { items: {
@ -98,6 +128,9 @@ export default {
updateUserData(rowItem, newCreation) { updateUserData(rowItem, newCreation) {
rowItem.creation = newCreation rowItem.creation = newCreation
}, },
updateState(id) {
this.$emit('update-state', id)
},
}, },
} }
</script> </script>

View File

@ -0,0 +1,15 @@
import gql from 'graphql-tag'
export const adminCreateContributionMessage = gql`
mutation ($contributionId: Float!, $message: String!) {
adminCreateContributionMessage(contributionId: $contributionId, message: $message) {
id
message
createdAt
updatedAt
type
userFirstName
userLastName
}
}
`

View File

@ -8,14 +8,15 @@ export const creationTransactionList = gql`
order: $order order: $order
userId: $userId userId: $userId
) { ) {
id contributionCount
amount contributionList {
balanceDate id
creationDate amount
memo createdAt
linkedUser { confirmedAt
firstName contributionDate
lastName memo
state
} }
} }
} }

View File

@ -0,0 +1,25 @@
import gql from 'graphql-tag'
export const listContributionMessages = gql`
query ($contributionId: Float!, $pageSize: Int = 25, $currentPage: Int = 1, $order: Order = ASC) {
listContributionMessages(
contributionId: $contributionId
pageSize: $pageSize
currentPage: $currentPage
order: $order
) {
count
messages {
id
message
createdAt
updatedAt
type
userFirstName
userLastName
userId
isModerator
}
}
}
`

View File

@ -6,12 +6,15 @@ export const listUnconfirmedContributions = gql`
id id
firstName firstName
lastName lastName
userId
email email
amount amount
memo memo
date date
moderator moderator
creation creation
state
messageCount
} }
} }
` `

View File

@ -1,18 +0,0 @@
import gql from 'graphql-tag'
export const showContributionLink = gql`
query ($id: Int!) {
showContributionLink {
id
validFrom
validTo
name
memo
amount
cycle
maxPerCycle
maxAmountPerMonth
code
}
}
`

View File

@ -7,7 +7,8 @@
"contributionLinks": "Beitragslinks", "contributionLinks": "Beitragslinks",
"create": "Anlegen", "create": "Anlegen",
"cycle": "Zyklus", "cycle": "Zyklus",
"deleteNow": "Automatische Creations wirklich löschen?", "deleted": "Automatische Schöpfung gelöscht!",
"deleteNow": "Automatische Creations '{name}' wirklich löschen?",
"maximumAmount": "maximaler Betrag", "maximumAmount": "maximaler Betrag",
"maxPerCycle": "Wiederholungen", "maxPerCycle": "Wiederholungen",
"memo": "Nachricht", "memo": "Nachricht",
@ -69,15 +70,32 @@
}, },
"short_hash": "({shortHash})" "short_hash": "({shortHash})"
}, },
"form": {
"cancel": "Abbrechen",
"submit": "Senden"
},
"GDD": "GDD", "GDD": "GDD",
"help": {
"help": "Hilfe",
"transactionlist": {
"confirmed": "Wann wurde es von einem Moderator / Admin bestätigt.",
"periods": "Für welchen Zeitraum wurde vom Mitglied eingereicht.",
"state": "[PENDING = eingereicht, DELETED = gelöscht, IN_PROGRESS = im Dialog mit Moderator, DENIED = abgelehnt, CONFIRMED = bestätigt]",
"submitted": "Wann wurde es vom Mitglied eingereicht"
}
},
"hide_details": "Details verbergen", "hide_details": "Details verbergen",
"lastname": "Nachname", "lastname": "Nachname",
"math": { "math": {
"colon": ":", "colon": ":",
"equals": "=",
"exclaim": "!", "exclaim": "!",
"pipe": "|", "pipe": "|",
"plus": "+" "plus": "+"
}, },
"message": {
"request": "Die Anfrage wurde gesendet."
},
"moderator": "Moderator", "moderator": "Moderator",
"multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.", "multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.",
"name": "Name", "name": "Name",
@ -126,10 +144,11 @@
}, },
"transactionlist": { "transactionlist": {
"amount": "Betrag", "amount": "Betrag",
"balanceDate": "Schöpfungsdatum", "confirmed": "Bestätigt",
"community": "Gemeinschaft",
"date": "Datum",
"memo": "Nachricht", "memo": "Nachricht",
"period": "Zeitraum",
"state": "Status",
"submitted": "Eingereicht",
"title": "Alle geschöpften Transaktionen für den Nutzer" "title": "Alle geschöpften Transaktionen für den Nutzer"
}, },
"undelete_user": "Nutzer wiederherstellen", "undelete_user": "Nutzer wiederherstellen",

View File

@ -7,7 +7,8 @@
"contributionLinks": "Contribution Links", "contributionLinks": "Contribution Links",
"create": "Create", "create": "Create",
"cycle": "Cycle", "cycle": "Cycle",
"deleteNow": "Do you really delete automatic creations?", "deleted": "Automatic creation deleted!",
"deleteNow": "Do you really delete automatic creations '{name}'?",
"maximumAmount": "Maximum amount", "maximumAmount": "Maximum amount",
"maxPerCycle": "Repetition", "maxPerCycle": "Repetition",
"memo": "Memo", "memo": "Memo",
@ -69,15 +70,32 @@
}, },
"short_hash": "({shortHash})" "short_hash": "({shortHash})"
}, },
"form": {
"cancel": "Cancel",
"submit": "Send"
},
"GDD": "GDD", "GDD": "GDD",
"help": {
"help": "Help",
"transactionlist": {
"confirmed": "When was it confirmed by a moderator / admin.",
"periods": "For what period was it submitted by the member.",
"state": "[PENDING = submitted, DELETED = deleted, IN_PROGRESS = in dialogue with moderator, DENIED = denied, CONFIRMED = confirmed]",
"submitted": "When was it submitted by the member"
}
},
"hide_details": "Hide details", "hide_details": "Hide details",
"lastname": "Lastname", "lastname": "Lastname",
"math": { "math": {
"colon": ":", "colon": ":",
"equals": "=",
"exclaim": "!", "exclaim": "!",
"pipe": "|", "pipe": "|",
"plus": "+" "plus": "+"
}, },
"message": {
"request": "Request has been sent."
},
"moderator": "Moderator", "moderator": "Moderator",
"multiple_creation_text": "Please select one or more members for which you would like to perform creations.", "multiple_creation_text": "Please select one or more members for which you would like to perform creations.",
"name": "Name", "name": "Name",
@ -126,10 +144,11 @@
}, },
"transactionlist": { "transactionlist": {
"amount": "Amount", "amount": "Amount",
"balanceDate": "Creation date", "confirmed": "Confirmed",
"community": "Community",
"date": "Date",
"memo": "Message", "memo": "Message",
"period": "Period",
"state": "State",
"submitted": "Submitted",
"title": "All creation-transactions for the user" "title": "All creation-transactions for the user"
}, },
"undelete_user": "Undelete User", "undelete_user": "Undelete User",

View File

@ -14,21 +14,23 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
id: 1, id: 1,
firstName: 'Bibi', firstName: 'Bibi',
lastName: 'Bloxberg', lastName: 'Bloxberg',
userId: 99,
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: 500, amount: 500,
memo: 'Danke für alles', memo: 'Danke für alles',
date: new Date(), date: new Date(),
moderator: 2, moderator: 1,
}, },
{ {
id: 2, id: 2,
firstName: 'Räuber', firstName: 'Räuber',
lastName: 'Hotzenplotz', lastName: 'Hotzenplotz',
userId: 100,
email: 'raeuber@hotzenplotz.de', email: 'raeuber@hotzenplotz.de',
amount: 1000000, amount: 1000000,
memo: 'Gut Ergattert', memo: 'Gut Ergattert',
date: new Date(), date: new Date(),
moderator: 2, moderator: 1,
}, },
], ],
}, },
@ -41,6 +43,15 @@ const mocks = {
$d: jest.fn((d) => d), $d: jest.fn((d) => d),
$store: { $store: {
commit: storeCommitMock, commit: storeCommitMock,
state: {
moderator: {
firstName: 'Peter',
lastName: 'Lustig',
isAdmin: '2022-08-30T07:41:31.000Z',
id: 263,
language: 'de',
},
},
}, },
$apollo: { $apollo: {
query: apolloQueryMock, query: apolloQueryMock,

View File

@ -9,6 +9,7 @@
:fields="fields" :fields="fields"
@remove-creation="removeCreation" @remove-creation="removeCreation"
@show-overlay="showOverlay" @show-overlay="showOverlay"
@update-state="updateState"
/> />
</div> </div>
</template> </template>
@ -93,6 +94,10 @@ export default {
this.overlay = true this.overlay = true
this.item = item this.item = item
}, },
updateState(id) {
this.pendingCreations.find((obj) => obj.id === id).messagesCount++
this.pendingCreations.find((obj) => obj.id === id).state = 'IN_PROGRESS'
},
}, },
computed: { computed: {
fields() { fields() {

View File

@ -28,7 +28,11 @@
</b-link> </b-link>
</b-card-text> </b-card-text>
</b-card> </b-card>
<contribution-link :items="items" :count="count" /> <contribution-link
:items="items"
:count="count"
@get-contribution-links="getContributionLinks"
/>
<community-statistic class="mt-5" v-model="statistics" /> <community-statistic class="mt-5" v-model="statistics" />
</div> </div>
</template> </template>
@ -89,7 +93,6 @@ export default {
this.$apollo this.$apollo
.query({ .query({
query: communityStatistics, query: communityStatistics,
fetchPolicy: 'network-only',
}) })
.then((result) => { .then((result) => {
this.statistics.totalUsers = result.data.communityStatistics.totalUsers this.statistics.totalUsers = result.data.communityStatistics.totalUsers

View File

@ -10,6 +10,7 @@ const authLink = new ApolloLink((operation, forward) => {
operation.setContext({ operation.setContext({
headers: { headers: {
Authorization: token && token.length > 0 ? `Bearer ${token}` : '', Authorization: token && token.length > 0 ? `Bearer ${token}` : '',
clientRequestTime: new Date().toString(),
}, },
}) })
return forward(operation).map((response) => { return forward(operation).map((response) => {

View File

@ -94,6 +94,7 @@ describe('apolloProvider', () => {
expect(setContextMock).toBeCalledWith({ expect(setContextMock).toBeCalledWith({
headers: { headers: {
Authorization: 'Bearer some-token', Authorization: 'Bearer some-token',
clientRequestTime: expect.any(String),
}, },
}) })
}) })
@ -109,6 +110,7 @@ describe('apolloProvider', () => {
expect(setContextMock).toBeCalledWith({ expect(setContextMock).toBeCalledWith({
headers: { headers: {
Authorization: '', Authorization: '',
clientRequestTime: expect.any(String),
}, },
}) })
}) })

View File

@ -1,4 +1,4 @@
CONFIG_VERSION=v9.2022-07-07 CONFIG_VERSION=v10.2022-09-20
# Server # Server
PORT=4000 PORT=4000
@ -37,6 +37,8 @@ LOGIN_SERVER_KEY=a51ef8ac7ef1abf162fb7a65261acd7a
# EMail # EMail
EMAIL=false EMAIL=false
EMAIL_TEST_MODUS=false
EMAIL_TEST_RECEIVER=stage1@gradido.net
EMAIL_USERNAME=gradido_email EMAIL_USERNAME=gradido_email
EMAIL_SENDER=info@gradido.net EMAIL_SENDER=info@gradido.net
EMAIL_PASSWORD=xxx EMAIL_PASSWORD=xxx

View File

@ -36,6 +36,8 @@ LOGIN_SERVER_KEY=a51ef8ac7ef1abf162fb7a65261acd7a
# EMail # EMail
EMAIL=$EMAIL EMAIL=$EMAIL
EMAIL_TEST_MODUS=$EMAIL_TEST_MODUS
EMAIL_TEST_RECEIVER=$EMAIL_TEST_RECEIVER
EMAIL_USERNAME=$EMAIL_USERNAME EMAIL_USERNAME=$EMAIL_USERNAME
EMAIL_SENDER=$EMAIL_SENDER EMAIL_SENDER=$EMAIL_SENDER
EMAIL_PASSWORD=$EMAIL_PASSWORD EMAIL_PASSWORD=$EMAIL_PASSWORD

View File

@ -1,6 +1,6 @@
{ {
"name": "gradido-backend", "name": "gradido-backend",
"version": "1.11.0", "version": "1.12.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",
@ -14,7 +14,8 @@
"dev": "cross-env TZ=UTC nodemon -w src --ext ts --exec ts-node -r tsconfig-paths/register src/index.ts", "dev": "cross-env TZ=UTC nodemon -w src --ext ts --exec ts-node -r tsconfig-paths/register src/index.ts",
"lint": "eslint --max-warnings=0 --ext .js,.ts .", "lint": "eslint --max-warnings=0 --ext .js,.ts .",
"test": "cross-env TZ=UTC NODE_ENV=development jest --runInBand --coverage --forceExit --detectOpenHandles", "test": "cross-env TZ=UTC NODE_ENV=development jest --runInBand --coverage --forceExit --detectOpenHandles",
"seed": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/index.ts" "seed": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/index.ts",
"klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/klicktipp.ts"
}, },
"dependencies": { "dependencies": {
"@types/jest": "^27.0.2", "@types/jest": "^27.0.2",

View File

@ -33,6 +33,8 @@ export enum RIGHTS {
LIST_CONTRIBUTION_LINKS = 'LIST_CONTRIBUTION_LINKS', LIST_CONTRIBUTION_LINKS = 'LIST_CONTRIBUTION_LINKS',
COMMUNITY_STATISTICS = 'COMMUNITY_STATISTICS', COMMUNITY_STATISTICS = 'COMMUNITY_STATISTICS',
SEARCH_ADMIN_USERS = 'SEARCH_ADMIN_USERS', SEARCH_ADMIN_USERS = 'SEARCH_ADMIN_USERS',
CREATE_CONTRIBUTION_MESSAGE = 'CREATE_CONTRIBUTION_MESSAGE',
LIST_ALL_CONTRIBUTION_MESSAGES = 'LIST_ALL_CONTRIBUTION_MESSAGES',
// Admin // Admin
SEARCH_USERS = 'SEARCH_USERS', SEARCH_USERS = 'SEARCH_USERS',
SET_USER_ROLE = 'SET_USER_ROLE', SET_USER_ROLE = 'SET_USER_ROLE',
@ -50,4 +52,5 @@ export enum RIGHTS {
CREATE_CONTRIBUTION_LINK = 'CREATE_CONTRIBUTION_LINK', CREATE_CONTRIBUTION_LINK = 'CREATE_CONTRIBUTION_LINK',
DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK', DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK',
UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK', UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK',
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE',
} }

View File

@ -31,6 +31,8 @@ export const ROLE_USER = new Role('user', [
RIGHTS.SEARCH_ADMIN_USERS, RIGHTS.SEARCH_ADMIN_USERS,
RIGHTS.LIST_CONTRIBUTION_LINKS, RIGHTS.LIST_CONTRIBUTION_LINKS,
RIGHTS.COMMUNITY_STATISTICS, RIGHTS.COMMUNITY_STATISTICS,
RIGHTS.CREATE_CONTRIBUTION_MESSAGE,
RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES,
]) ])
export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights

View File

@ -10,14 +10,14 @@ Decimal.set({
}) })
const constants = { const constants = {
DB_VERSION: '0047-messages_tables', DB_VERSION: '0049-add_user_contacts_table',
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
LOG_LEVEL: process.env.LOG_LEVEL || 'info', LOG_LEVEL: process.env.LOG_LEVEL || 'info',
CONFIG_VERSION: { CONFIG_VERSION: {
DEFAULT: 'DEFAULT', DEFAULT: 'DEFAULT',
EXPECTED: 'v9.2022-07-07', EXPECTED: 'v10.2022-09-20',
CURRENT: '', CURRENT: '',
}, },
} }
@ -67,6 +67,8 @@ 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_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',
EMAIL_PASSWORD: process.env.EMAIL_PASSWORD || 'xxx', EMAIL_PASSWORD: process.env.EMAIL_PASSWORD || 'xxx',

View File

@ -32,6 +32,7 @@ export class EventRegister extends EventBasicUserId {}
export class EventRedeemRegister extends EventBasicRedeem {} export class EventRedeemRegister 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 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 {}
@ -113,6 +114,15 @@ export class Event {
return this return this
} }
public setEventSendAccountMultiRegistrationEmail(
ev: EventSendAccountMultiRegistrationEmail,
): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL
return this
}
public setEventConfirmationEmail(ev: EventConfirmationEmail): Event { public setEventConfirmationEmail(ev: EventConfirmationEmail): Event {
this.setByBasicUser(ev.userId) this.setByBasicUser(ev.userId)
this.type = EventProtocolType.CONFIRM_EMAIL this.type = EventProtocolType.CONFIRM_EMAIL

View File

@ -5,6 +5,7 @@ export enum EventProtocolType {
REDEEM_REGISTER = 'REDEEM_REGISTER', REDEEM_REGISTER = 'REDEEM_REGISTER',
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',
CONFIRM_EMAIL = 'CONFIRM_EMAIL', CONFIRM_EMAIL = 'CONFIRM_EMAIL',
REGISTER_EMAIL_KLICKTIPP = 'REGISTER_EMAIL_KLICKTIPP', REGISTER_EMAIL_KLICKTIPP = 'REGISTER_EMAIL_KLICKTIPP',
LOGIN = 'LOGIN', LOGIN = 'LOGIN',

View File

@ -0,0 +1,11 @@
import { ArgsType, Field, InputType } from 'type-graphql'
@InputType()
@ArgsType()
export default class ContributionMessageArgs {
@Field(() => Number)
contributionId: number
@Field(() => String)
message: string
}

View File

@ -31,7 +31,7 @@ const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
// TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests // TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests
// TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey // TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey
const userRepository = await getCustomRepository(UserRepository) const userRepository = getCustomRepository(UserRepository)
try { try {
const user = await userRepository.findByPubkeyHex(context.pubKey) const user = await userRepository.findByPubkeyHex(context.pubKey)
context.user = user context.user = user

View File

@ -0,0 +1,11 @@
import { registerEnumType } from 'type-graphql'
export enum UserContactType {
USER_CONTACT_EMAIL = 'EMAIL',
USER_CONTACT_PHONE = 'PHONE',
}
registerEnumType(UserContactType, {
name: 'UserContactType', // this one is mandatory
description: 'Type of the user contact', // this one is optional
})

View File

@ -1,11 +1,11 @@
import { ObjectType, Field, Int } from 'type-graphql' import { ObjectType, Field, Int } from 'type-graphql'
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import { Contribution as dbContribution } from '@entity/Contribution' import { Contribution as dbContribution } from '@entity/Contribution'
import { User } from './User' import { User } from '@entity/User'
@ObjectType() @ObjectType()
export class Contribution { export class Contribution {
constructor(contribution: dbContribution, user: User) { constructor(contribution: dbContribution, user?: User | null) {
this.id = contribution.id this.id = contribution.id
this.firstName = user ? user.firstName : null this.firstName = user ? user.firstName : null
this.lastName = user ? user.lastName : null this.lastName = user ? user.lastName : null
@ -16,6 +16,8 @@ export class Contribution {
this.confirmedAt = contribution.confirmedAt this.confirmedAt = contribution.confirmedAt
this.confirmedBy = contribution.confirmedBy this.confirmedBy = contribution.confirmedBy
this.contributionDate = contribution.contributionDate this.contributionDate = contribution.contributionDate
this.state = contribution.contributionStatus
this.messagesCount = contribution.messages ? contribution.messages.length : 0
} }
@Field(() => Number) @Field(() => Number)
@ -47,6 +49,12 @@ export class Contribution {
@Field(() => Date) @Field(() => Date)
contributionDate: Date contributionDate: Date
@Field(() => Number)
messagesCount: number
@Field(() => String)
state: string
} }
@ObjectType() @ObjectType()

View File

@ -0,0 +1,53 @@
import { Field, ObjectType } from 'type-graphql'
import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage'
import { User } from '@entity/User'
@ObjectType()
export class ContributionMessage {
constructor(contributionMessage: DbContributionMessage, user: User) {
this.id = contributionMessage.id
this.message = contributionMessage.message
this.createdAt = contributionMessage.createdAt
this.updatedAt = contributionMessage.updatedAt
this.type = contributionMessage.type
this.userFirstName = user.firstName
this.userLastName = user.lastName
this.userId = user.id
this.isModerator = contributionMessage.isModerator
}
@Field(() => Number)
id: number
@Field(() => String)
message: string
@Field(() => Date)
createdAt: Date
@Field(() => Date, { nullable: true })
updatedAt?: Date | null
@Field(() => String)
type: string
@Field(() => String, { nullable: true })
userFirstName: string | null
@Field(() => String, { nullable: true })
userLastName: string | null
@Field(() => Number, { nullable: true })
userId: number | null
@Field(() => Boolean)
isModerator: boolean
}
@ObjectType()
export class ContributionMessageListResult {
@Field(() => Number)
count: number
@Field(() => [ContributionMessage])
messages: ContributionMessage[]
}

View File

@ -5,7 +5,7 @@ import { User } from '@entity/User'
@ObjectType() @ObjectType()
export class UnconfirmedContribution { export class UnconfirmedContribution {
constructor(contribution: Contribution, user: User, creations: Decimal[]) { constructor(contribution: Contribution, user: User | undefined, creations: Decimal[]) {
this.id = contribution.id this.id = contribution.id
this.userId = contribution.userId this.userId = contribution.userId
this.amount = contribution.amount this.amount = contribution.amount
@ -13,8 +13,11 @@ export class UnconfirmedContribution {
this.date = contribution.contributionDate this.date = contribution.contributionDate
this.firstName = user ? user.firstName : '' this.firstName = user ? user.firstName : ''
this.lastName = user ? user.lastName : '' this.lastName = user ? user.lastName : ''
this.email = user ? user.email : '' this.email = user ? user.emailContact.email : ''
this.moderator = contribution.moderatorId
this.creation = creations this.creation = creations
this.state = contribution.contributionStatus
this.messageCount = contribution.messages ? contribution.messages.length : 0
} }
@Field(() => String) @Field(() => String)
@ -46,4 +49,10 @@ export class UnconfirmedContribution {
@Field(() => [Decimal]) @Field(() => [Decimal])
creation: Decimal[] creation: Decimal[]
@Field(() => String)
state: string
@Field(() => Number)
messageCount: number
} }

View File

@ -3,6 +3,7 @@ import { KlickTipp } from './KlickTipp'
import { User as dbUser } from '@entity/User' import { User as dbUser } from '@entity/User'
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import { FULL_CREATION_AVAILABLE } from '../resolver/const/const' import { FULL_CREATION_AVAILABLE } from '../resolver/const/const'
import { UserContact } from './UserContact'
@ObjectType() @ObjectType()
export class User { export class User {
@ -10,12 +11,16 @@ export class User {
this.id = user.id this.id = user.id
this.gradidoID = user.gradidoID this.gradidoID = user.gradidoID
this.alias = user.alias this.alias = user.alias
this.email = user.email this.emailId = user.emailId
if (user.emailContact) {
this.email = user.emailContact.email
this.emailContact = new UserContact(user.emailContact)
this.emailChecked = user.emailContact.emailChecked
}
this.firstName = user.firstName this.firstName = user.firstName
this.lastName = user.lastName this.lastName = user.lastName
this.deletedAt = user.deletedAt this.deletedAt = user.deletedAt
this.createdAt = user.createdAt this.createdAt = user.createdAt
this.emailChecked = user.emailChecked
this.language = user.language this.language = user.language
this.publisherId = user.publisherId this.publisherId = user.publisherId
this.isAdmin = user.isAdmin this.isAdmin = user.isAdmin
@ -34,12 +39,18 @@ export class User {
gradidoID: string gradidoID: string
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
alias: string alias?: string
@Field(() => Number, { nullable: true })
emailId: number | null
// TODO privacy issue here // TODO privacy issue here
@Field(() => String) @Field(() => String, { nullable: true })
email: string email: string
@Field(() => UserContact)
emailContact: UserContact
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
firstName: string | null firstName: string | null

View File

@ -6,11 +6,11 @@ import { User } from '@entity/User'
export class UserAdmin { export class UserAdmin {
constructor(user: User, creation: Decimal[], hasElopage: boolean, emailConfirmationSend: string) { constructor(user: User, creation: Decimal[], hasElopage: boolean, emailConfirmationSend: string) {
this.userId = user.id this.userId = user.id
this.email = user.email this.email = user.emailContact.email
this.firstName = user.firstName this.firstName = user.firstName
this.lastName = user.lastName this.lastName = user.lastName
this.creation = creation this.creation = creation
this.emailChecked = user.emailChecked this.emailChecked = user.emailContact.emailChecked
this.hasElopage = hasElopage this.hasElopage = hasElopage
this.deletedAt = user.deletedAt this.deletedAt = user.deletedAt
this.emailConfirmationSend = emailConfirmationSend this.emailConfirmationSend = emailConfirmationSend

View File

@ -0,0 +1,56 @@
import { ObjectType, Field } from 'type-graphql'
import { UserContact as dbUserContact } from '@entity/UserContact'
@ObjectType()
export class UserContact {
constructor(userContact: dbUserContact) {
this.id = userContact.id
this.type = userContact.type
this.userId = userContact.userId
this.email = userContact.email
// this.emailVerificationCode = userContact.emailVerificationCode
this.emailOptInTypeId = userContact.emailOptInTypeId
this.emailResendCount = userContact.emailResendCount
this.emailChecked = userContact.emailChecked
this.phone = userContact.phone
this.createdAt = userContact.createdAt
this.updatedAt = userContact.updatedAt
this.deletedAt = userContact.deletedAt
}
@Field(() => Number)
id: number
@Field(() => String)
type: string
@Field(() => Number)
userId: number
@Field(() => String)
email: string
// @Field(() => BigInt, { nullable: true })
// emailVerificationCode: BigInt | null
@Field(() => Number, { nullable: true })
emailOptInTypeId: number | null
@Field(() => Number, { nullable: true })
emailResendCount: number | null
@Field(() => Boolean)
emailChecked: boolean
@Field(() => String, { nullable: true })
phone: string | null
@Field(() => Date)
createdAt: Date
@Field(() => Date, { nullable: true })
updatedAt: Date | null
@Field(() => Date, { nullable: true })
deletedAt: Date | null
}

View File

@ -40,6 +40,7 @@ import Decimal from 'decimal.js-light'
import { Contribution } from '@entity/Contribution' 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'
// mock account activation email to avoid console spam // mock account activation email to avoid console spam
jest.mock('@/mailer/sendAccountActivationEmail', () => { jest.mock('@/mailer/sendAccountActivationEmail', () => {
@ -49,6 +50,14 @@ jest.mock('@/mailer/sendAccountActivationEmail', () => {
} }
}) })
// mock account activation email to avoid console spam
jest.mock('@/mailer/sendContributionConfirmedEmail', () => {
return {
__esModule: true,
sendContributionConfirmedEmail: jest.fn(),
}
})
let mutate: any, query: any, con: any let mutate: any, query: any, con: any
let testEnv: any let testEnv: any
@ -1117,7 +1126,9 @@ describe('AdminResolver', () => {
}), }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [new GraphQLError('Could not find user with email: bob@baumeister.de')], errors: [
new GraphQLError('Could not find UserContact with email: bob@baumeister.de'),
],
}), }),
) )
}) })
@ -1450,6 +1461,20 @@ describe('AdminResolver', () => {
expect(transaction[0].linkedUserId).toEqual(null) expect(transaction[0].linkedUserId).toEqual(null)
expect(transaction[0].typeId).toEqual(1) expect(transaction[0].typeId).toEqual(1)
}) })
it('calls sendContributionConfirmedEmail', async () => {
expect(sendContributionConfirmedEmail).toBeCalledWith(
expect.objectContaining({
contributionMemo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
overviewURL: 'http://localhost/overview',
recipientEmail: 'bibi@bloxberg.de',
recipientFirstName: 'Bibi',
recipientLastName: 'Bloxberg',
senderFirstName: 'Peter',
senderLastName: 'Lustig',
}),
)
})
}) })
describe('confirm two creations one after the other quickly', () => { describe('confirm two creations one after the other quickly', () => {
@ -1493,6 +1518,7 @@ describe('AdminResolver', () => {
) )
await expect(r2).resolves.toEqual( await expect(r2).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
// data: { confirmContribution: true },
errors: [new GraphQLError('Creation was not successful.')], errors: [new GraphQLError('Creation was not successful.')],
}), }),
) )

View File

@ -4,8 +4,6 @@ import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type
import { import {
getCustomRepository, getCustomRepository,
IsNull, IsNull,
Not,
ObjectLiteral,
getConnection, getConnection,
In, In,
MoreThan, MoreThan,
@ -17,6 +15,7 @@ import { AdminCreateContributions } from '@model/AdminCreateContributions'
import { AdminUpdateContribution } from '@model/AdminUpdateContribution' import { AdminUpdateContribution } from '@model/AdminUpdateContribution'
import { ContributionLink } from '@model/ContributionLink' import { ContributionLink } from '@model/ContributionLink'
import { ContributionLinkList } from '@model/ContributionLinkList' import { ContributionLinkList } from '@model/ContributionLinkList'
import { Contribution } from '@model/Contribution'
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
import { UserRepository } from '@repository/User' import { UserRepository } from '@repository/User'
import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs' import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs'
@ -25,14 +24,11 @@ import SearchUsersArgs from '@arg/SearchUsersArgs'
import ContributionLinkArgs from '@arg/ContributionLinkArgs' import ContributionLinkArgs from '@arg/ContributionLinkArgs'
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 { Transaction } from '@model/Transaction'
import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { TransactionRepository } from '@repository/Transaction'
import { calculateDecay } from '@/util/decay' import { calculateDecay } from '@/util/decay'
import { Contribution } from '@entity/Contribution' import { Contribution as DbContribution } from '@entity/Contribution'
import { hasElopageBuys } from '@/util/hasElopageBuys' import { hasElopageBuys } from '@/util/hasElopageBuys'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User as dbUser } from '@entity/User' import { User as dbUser } from '@entity/User'
import { User } from '@model/User' import { User } from '@model/User'
import { TransactionTypeId } from '@enum/TransactionTypeId' import { TransactionTypeId } from '@enum/TransactionTypeId'
@ -43,8 +39,7 @@ import { Decay } from '@model/Decay'
import Paginated from '@arg/Paginated' import Paginated from '@arg/Paginated'
import TransactionLinkFilters from '@arg/TransactionLinkFilters' import TransactionLinkFilters from '@arg/TransactionLinkFilters'
import { Order } from '@enum/Order' import { Order } from '@enum/Order'
import { communityUser } from '@/util/communityUser' import { findUserByEmail, activationLink, printTimeDuration } from './UserResolver'
import { checkOptInCode, activationLink, printTimeDuration } from './UserResolver'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver' import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver'
import CONFIG from '@/config' import CONFIG from '@/config'
@ -62,6 +57,14 @@ import {
MEMO_MAX_CHARS, MEMO_MAX_CHARS,
MEMO_MIN_CHARS, MEMO_MIN_CHARS,
} from './const/const' } from './const/const'
import { UserContact } from '@entity/UserContact'
import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage'
import ContributionMessageArgs from '@arg/ContributionMessageArgs'
import { ContributionMessageType } from '@enum/MessageType'
import { ContributionMessage } from '@model/ContributionMessage'
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail'
import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail'
import { ContributionListResult } from '../model/Contribution'
// const EMAIL_OPT_IN_REGISTER = 1 // const EMAIL_OPT_IN_REGISTER = 1
// const EMAIL_OPT_UNKNOWN = 3 // elopage? // const EMAIL_OPT_UNKNOWN = 3 // elopage?
@ -75,24 +78,12 @@ export class AdminResolver {
{ searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs,
): Promise<SearchUsersResult> { ): Promise<SearchUsersResult> {
const userRepository = getCustomRepository(UserRepository) const userRepository = getCustomRepository(UserRepository)
const filterCriteria: ObjectLiteral[] = []
if (filters) {
if (filters.byActivated !== null) {
filterCriteria.push({ emailChecked: filters.byActivated })
}
if (filters.byDeleted !== null) {
filterCriteria.push({ deletedAt: filters.byDeleted ? Not(IsNull()) : IsNull() })
}
}
const userFields = [ const userFields = [
'id', 'id',
'firstName', 'firstName',
'lastName', 'lastName',
'email', 'emailId',
'emailChecked', 'emailContact',
'deletedAt', 'deletedAt',
'isAdmin', 'isAdmin',
] ]
@ -101,7 +92,7 @@ export class AdminResolver {
return 'user.' + fieldName return 'user.' + fieldName
}), }),
searchText, searchText,
filterCriteria, filters,
currentPage, currentPage,
pageSize, pageSize,
) )
@ -118,32 +109,18 @@ export class AdminResolver {
const adminUsers = await Promise.all( const adminUsers = await Promise.all(
users.map(async (user) => { users.map(async (user) => {
let emailConfirmationSend = '' let emailConfirmationSend = ''
if (!user.emailChecked) { if (!user.emailContact.emailChecked) {
const emailOptIn = await LoginEmailOptIn.findOne( if (user.emailContact.updatedAt) {
{ emailConfirmationSend = user.emailContact.updatedAt.toISOString()
userId: user.id, } else {
}, emailConfirmationSend = user.emailContact.createdAt.toISOString()
{
order: {
updatedAt: 'DESC',
createdAt: 'DESC',
},
select: ['updatedAt', 'createdAt'],
},
)
if (emailOptIn) {
if (emailOptIn.updatedAt) {
emailConfirmationSend = emailOptIn.updatedAt.toISOString()
} else {
emailConfirmationSend = emailOptIn.createdAt.toISOString()
}
} }
} }
const userCreations = creations.find((c) => c.id === user.id) const userCreations = creations.find((c) => c.id === user.id)
const adminUser = new UserAdmin( const adminUser = new UserAdmin(
user, user,
userCreations ? userCreations.creations : FULL_CREATION_AVAILABLE, userCreations ? userCreations.creations : FULL_CREATION_AVAILABLE,
await hasElopageBuys(user.email), await hasElopageBuys(user.emailContact.email),
emailConfirmationSend, emailConfirmationSend,
) )
return adminUser return adminUser
@ -239,24 +216,39 @@ export class AdminResolver {
@Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs, @Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<Decimal[]> { ): Promise<Decimal[]> {
const user = await dbUser.findOne({ email }, { withDeleted: true }) logger.info(
if (!user) { `adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`,
)
const emailContact = await UserContact.findOne({
where: { email },
withDeleted: true,
relations: ['user'],
})
if (!emailContact) {
logger.error(`Could not find user with email: ${email}`)
throw new Error(`Could not find user with email: ${email}`) throw new Error(`Could not find user with email: ${email}`)
} }
if (user.deletedAt) { if (emailContact.deletedAt) {
logger.error('This emailContact was deleted. Cannot create a contribution.')
throw new Error('This emailContact was deleted. Cannot create a contribution.')
}
if (emailContact.user.deletedAt) {
logger.error('This user was deleted. Cannot create a contribution.')
throw new Error('This user was deleted. Cannot create a contribution.') throw new Error('This user was deleted. Cannot create a contribution.')
} }
if (!user.emailChecked) { if (!emailContact.emailChecked) {
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 moderator = getUser(context) const moderator = getUser(context)
logger.trace('moderator: ', moderator.id) logger.trace('moderator: ', moderator.id)
const creations = await getUserCreation(user.id) const creations = await getUserCreation(emailContact.userId)
logger.trace('creations', creations) logger.trace('creations:', creations)
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
logger.trace('creationDateObj:', creationDateObj)
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj)
const contribution = Contribution.create() const contribution = DbContribution.create()
contribution.userId = user.id contribution.userId = emailContact.userId
contribution.amount = amount contribution.amount = amount
contribution.createdAt = new Date() contribution.createdAt = new Date()
contribution.contributionDate = creationDateObj contribution.contributionDate = creationDateObj
@ -266,8 +258,8 @@ export class AdminResolver {
contribution.contributionStatus = ContributionStatus.PENDING contribution.contributionStatus = ContributionStatus.PENDING
logger.trace('contribution to save', contribution) logger.trace('contribution to save', contribution)
await Contribution.save(contribution) await DbContribution.save(contribution)
return getUserCreation(user.id) return getUserCreation(emailContact.userId)
} }
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS]) @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS])
@ -303,29 +295,43 @@ export class AdminResolver {
@Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs, @Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<AdminUpdateContribution> { ): Promise<AdminUpdateContribution> {
const user = await dbUser.findOne({ email }, { withDeleted: true }) const emailContact = await UserContact.findOne({
where: { email },
withDeleted: true,
relations: ['user'],
})
if (!emailContact) {
logger.error(`Could not find UserContact with email: ${email}`)
throw new Error(`Could not find UserContact with email: ${email}`)
}
const user = emailContact.user
if (!user) { if (!user) {
throw new Error(`Could not find user with email: ${email}`) logger.error(`Could not find User to emailContact: ${email}`)
throw new Error(`Could not find User to emailContact: ${email}`)
} }
if (user.deletedAt) { if (user.deletedAt) {
logger.error(`User was deleted (${email})`)
throw new Error(`User was deleted (${email})`) throw new Error(`User was deleted (${email})`)
} }
const moderator = getUser(context) const moderator = getUser(context)
const contributionToUpdate = await Contribution.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.')
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')
} }
if (contributionToUpdate.moderatorId === null) { if (contributionToUpdate.moderatorId === null) {
logger.error('An admin is not allowed to update a user contribution.')
throw new Error('An admin is not allowed to update a user contribution.') throw new Error('An admin is not allowed to update a user contribution.')
} }
@ -343,7 +349,7 @@ export class AdminResolver {
contributionToUpdate.moderatorId = moderator.id contributionToUpdate.moderatorId = moderator.id
contributionToUpdate.contributionStatus = ContributionStatus.PENDING contributionToUpdate.contributionStatus = ContributionStatus.PENDING
await Contribution.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
@ -357,39 +363,44 @@ export class AdminResolver {
@Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS]) @Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS])
@Query(() => [UnconfirmedContribution]) @Query(() => [UnconfirmedContribution])
async listUnconfirmedContributions(): Promise<UnconfirmedContribution[]> { async listUnconfirmedContributions(): Promise<UnconfirmedContribution[]> {
const contributions = await Contribution.find({ where: { confirmedAt: IsNull() } }) const contributions = await getConnection()
.createQueryBuilder()
.select('c')
.from(DbContribution, 'c')
.leftJoinAndSelect('c.messages', 'm')
.where({ confirmedAt: IsNull() })
.getMany()
if (contributions.length === 0) { if (contributions.length === 0) {
return [] return []
} }
const userIds = contributions.map((p) => p.userId) const userIds = contributions.map((p) => p.userId)
const userCreations = await getUserCreations(userIds) const userCreations = await getUserCreations(userIds)
const users = await dbUser.find({ where: { id: In(userIds) }, withDeleted: true }) const users = await dbUser.find({
where: { id: In(userIds) },
withDeleted: true,
relations: ['emailContact'],
})
return contributions.map((contribution) => { return contributions.map((contribution) => {
const user = users.find((u) => u.id === contribution.userId) const user = users.find((u) => u.id === contribution.userId)
const creation = userCreations.find((c) => c.id === contribution.userId) const creation = userCreations.find((c) => c.id === contribution.userId)
return { return new UnconfirmedContribution(
id: contribution.id, contribution,
userId: contribution.userId, user,
date: contribution.contributionDate, creation ? creation.creations : FULL_CREATION_AVAILABLE,
memo: contribution.memo, )
amount: contribution.amount,
moderator: contribution.moderatorId,
firstName: user ? user.firstName : '',
lastName: user ? user.lastName : '',
email: user ? user.email : '',
creation: creation ? creation.creations : FULL_CREATION_AVAILABLE,
}
}) })
} }
@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): Promise<boolean> {
const contribution = await Contribution.findOne(id) const contribution = await DbContribution.findOne(id)
if (!contribution) { if (!contribution) {
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.')
} }
contribution.contributionStatus = ContributionStatus.DELETED contribution.contributionStatus = ContributionStatus.DELETED
@ -404,17 +415,24 @@ export class AdminResolver {
@Arg('id', () => Int) id: number, @Arg('id', () => Int) id: number,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<boolean> { ): Promise<boolean> {
const contribution = await Contribution.findOne(id) const contribution = await DbContribution.findOne(id)
if (!contribution) { if (!contribution) {
logger.error(`Contribution not found for given id: ${id}`)
throw new Error('Contribution not found to given id.') throw new Error('Contribution not found to given id.')
} }
const moderatorUser = getUser(context) const moderatorUser = getUser(context)
if (moderatorUser.id === contribution.userId) if (moderatorUser.id === contribution.userId) {
logger.error('Moderator can not confirm own contribution')
throw new Error('Moderator can not confirm own contribution') throw new Error('Moderator can not confirm own contribution')
}
const user = await dbUser.findOneOrFail({ id: contribution.userId }, { withDeleted: true }) const user = await dbUser.findOneOrFail(
if (user.deletedAt) throw new Error('This user was deleted. Cannot confirm a contribution.') { id: contribution.userId },
{ withDeleted: true, relations: ['emailContact'] },
)
if (user.deletedAt) {
logger.error('This user was deleted. Cannot confirm a contribution.')
throw new Error('This user was deleted. Cannot confirm a contribution.')
}
const creations = await getUserCreation(contribution.userId, false) const creations = await getUserCreation(contribution.userId, false)
validateContribution(creations, contribution.amount, contribution.contributionDate) validateContribution(creations, contribution.amount, contribution.contributionDate)
@ -422,7 +440,7 @@ export class AdminResolver {
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect() await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED') await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED')
try { try {
const lastTransaction = await queryRunner.manager const lastTransaction = await queryRunner.manager
.createQueryBuilder() .createQueryBuilder()
@ -462,10 +480,20 @@ export class AdminResolver {
contribution.confirmedBy = moderatorUser.id contribution.confirmedBy = moderatorUser.id
contribution.transactionId = transaction.id contribution.transactionId = transaction.id
contribution.contributionStatus = ContributionStatus.CONFIRMED contribution.contributionStatus = ContributionStatus.CONFIRMED
await queryRunner.manager.update(Contribution, { id: contribution.id }, contribution) await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution)
await queryRunner.commitTransaction() await queryRunner.commitTransaction()
logger.info('creation commited successfuly.') logger.info('creation commited successfuly.')
sendContributionConfirmedEmail({
senderFirstName: moderatorUser.firstName,
senderLastName: moderatorUser.lastName,
recipientFirstName: user.firstName,
recipientLastName: user.lastName,
recipientEmail: user.emailContact.email,
contributionMemo: contribution.memo,
contributionAmount: contribution.amount,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
})
} catch (e) { } catch (e) {
await queryRunner.rollbackTransaction() await queryRunner.rollbackTransaction()
logger.error(`Creation was not successful: ${e}`) logger.error(`Creation was not successful: ${e}`)
@ -477,56 +505,64 @@ export class AdminResolver {
} }
@Authorized([RIGHTS.CREATION_TRANSACTION_LIST]) @Authorized([RIGHTS.CREATION_TRANSACTION_LIST])
@Query(() => [Transaction]) @Query(() => ContributionListResult)
async creationTransactionList( async creationTransactionList(
@Args() @Args()
{ currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated, { currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
@Arg('userId', () => Int) userId: number, @Arg('userId', () => Int) userId: number,
): Promise<Transaction[]> { ): Promise<ContributionListResult> {
const offset = (currentPage - 1) * pageSize const offset = (currentPage - 1) * pageSize
const transactionRepository = getCustomRepository(TransactionRepository) const [contributionResult, count] = await getConnection()
const [userTransactions] = await transactionRepository.findByUserPaged( .createQueryBuilder()
userId, .select('c')
pageSize, .from(DbContribution, 'c')
offset, .leftJoinAndSelect('c.user', 'u')
order, .where(`user_id = ${userId}`)
true, .limit(pageSize)
) .offset(offset)
.orderBy('c.created_at', order)
.getManyAndCount()
const user = await dbUser.findOneOrFail({ id: userId }) return new ContributionListResult(
return userTransactions.map((t) => new Transaction(t, new User(user), communityUser)) count,
contributionResult.map((contribution) => new Contribution(contribution, contribution.user)),
)
// return userTransactions.map((t) => new Transaction(t, new User(user), communityUser))
} }
@Authorized([RIGHTS.SEND_ACTIVATION_EMAIL]) @Authorized([RIGHTS.SEND_ACTIVATION_EMAIL])
@Mutation(() => Boolean) @Mutation(() => Boolean)
async sendActivationEmail(@Arg('email') email: string): Promise<boolean> { async sendActivationEmail(@Arg('email') email: string): Promise<boolean> {
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
const user = await dbUser.findOneOrFail({ email: email }) // const user = await dbUser.findOne({ id: emailContact.userId })
const user = await findUserByEmail(email)
// can be both types: REGISTER and RESET_PASSWORD if (!user) {
let optInCode = await LoginEmailOptIn.findOne({ logger.error(`Could not find User to emailContact: ${email}`)
where: { userId: user.id }, throw new Error(`Could not find User to emailContact: ${email}`)
order: { updatedAt: 'DESC' }, }
}) if (user.deletedAt) {
logger.error(`User with emailContact: ${email} is deleted.`)
optInCode = await checkOptInCode(optInCode, user) throw new Error(`User with emailContact: ${email} is deleted.`)
}
const emailContact = user.emailContact
if (emailContact.deletedAt) {
logger.error(`The emailContact: ${email} of htis User is deleted.`)
throw new Error(`The emailContact: ${email} of htis User is deleted.`)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountActivationEmail({ const emailSent = await sendAccountActivationEmail({
link: activationLink(optInCode), link: activationLink(emailContact.emailVerificationCode),
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
email, email,
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME), duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
}) })
/* uncomment this, when you need the activation link on the console
// 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) {
// eslint-disable-next-line no-console logger.info(`Account confirmation link: ${activationLink}`)
console.log(`Account confirmation link: ${activationLink}`)
} }
*/
return true return true
} }
@ -696,4 +732,73 @@ export class AdminResolver {
logger.debug(`updateContributionLink successful!`) logger.debug(`updateContributionLink successful!`)
return new ContributionLink(dbContributionLink) return new ContributionLink(dbContributionLink)
} }
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE])
@Mutation(() => ContributionMessage)
async adminCreateContributionMessage(
@Args() { contributionId, message }: ContributionMessageArgs,
@Ctx() context: Context,
): Promise<ContributionMessage> {
const user = getUser(context)
if (!user.emailContact) {
user.emailContact = await UserContact.findOneOrFail({ where: { id: user.emailId } })
}
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('REPEATABLE READ')
const contributionMessage = DbContributionMessage.create()
try {
const contribution = await DbContribution.findOne({
where: { id: contributionId },
relations: ['user'],
})
if (!contribution) {
throw new Error('Contribution not found')
}
if (contribution.userId === user.id) {
throw new Error('Admin can not answer on own contribution')
}
if (!contribution.user.emailContact) {
contribution.user.emailContact = await UserContact.findOneOrFail({
where: { id: contribution.user.emailId },
})
}
contributionMessage.contributionId = contributionId
contributionMessage.createdAt = new Date()
contributionMessage.message = message
contributionMessage.userId = user.id
contributionMessage.type = ContributionMessageType.DIALOG
contributionMessage.isModerator = true
await queryRunner.manager.insert(DbContributionMessage, contributionMessage)
if (
contribution.contributionStatus === ContributionStatus.DELETED ||
contribution.contributionStatus === ContributionStatus.DENIED ||
contribution.contributionStatus === ContributionStatus.PENDING
) {
contribution.contributionStatus = ContributionStatus.IN_PROGRESS
await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution)
}
await sendAddedContributionMessageEmail({
senderFirstName: user.firstName,
senderLastName: user.lastName,
recipientFirstName: contribution.user.firstName,
recipientLastName: contribution.user.lastName,
recipientEmail: contribution.user.emailContact.email,
senderEmail: user.emailContact.email,
contributionMemo: contribution.memo,
message,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
})
await queryRunner.commitTransaction()
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`ContributionMessage was not successful: ${e}`)
throw new Error(`ContributionMessage was not successful: ${e}`)
} finally {
await queryRunner.release()
}
return new ContributionMessage(contributionMessage, user)
}
} }

View File

@ -0,0 +1,351 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { cleanDB, resetToken, testEnvironment } from '@test/helpers'
import { GraphQLError } from 'graphql'
import {
adminCreateContributionMessage,
createContribution,
createContributionMessage,
} from '@/seeds/graphql/mutations'
import { listContributionMessages, login } from '@/seeds/graphql/queries'
import { userFactory } from '@/seeds/factory/user'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail'
jest.mock('@/mailer/sendAddedContributionMessageEmail', () => {
return {
__esModule: true,
sendAddedContributionMessageEmail: jest.fn(),
}
})
let mutate: any, query: any, con: any
let testEnv: any
let result: any
beforeAll(async () => {
testEnv = await testEnvironment()
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
describe('ContributionMessageResolver', () => {
describe('adminCreateContributionMessage', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
mutate({
mutation: adminCreateContributionMessage,
variables: { contributionId: 1, message: 'This is a test message' },
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
result = await mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
})
await query({
query: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
afterAll(() => {
resetToken()
})
describe('input not valid', () => {
it('throws error when contribution does not exist', async () => {
await expect(
mutate({
mutation: adminCreateContributionMessage,
variables: {
contributionId: -1,
message: 'Test',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
'ContributionMessage was not successful: Error: Contribution not found',
),
],
}),
)
})
it('throws error when contribution.userId equals user.id', async () => {
await query({
query: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
const result2 = await mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
})
await expect(
mutate({
mutation: adminCreateContributionMessage,
variables: {
contributionId: result2.data.createContribution.id,
message: 'Test',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
'ContributionMessage was not successful: Error: Admin can not answer on own contribution',
),
],
}),
)
})
})
describe('valid input', () => {
it('creates ContributionMessage', async () => {
await expect(
mutate({
mutation: adminCreateContributionMessage,
variables: {
contributionId: result.data.createContribution.id,
message: 'Admin Test',
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
adminCreateContributionMessage: expect.objectContaining({
id: expect.any(Number),
message: 'Admin Test',
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
}),
},
}),
)
})
it('calls sendAddedContributionMessageEmail', async () => {
expect(sendAddedContributionMessageEmail).toBeCalledWith({
senderFirstName: 'Peter',
senderLastName: 'Lustig',
recipientFirstName: 'Bibi',
recipientLastName: 'Bloxberg',
recipientEmail: 'bibi@bloxberg.de',
senderEmail: 'peter@lustig.de',
contributionMemo: 'Test env contribution',
message: 'Admin Test',
overviewURL: 'http://localhost/overview',
})
})
})
})
})
describe('createContributionMessage', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
mutate({
mutation: createContributionMessage,
variables: { contributionId: 1, message: 'This is a test message' },
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
beforeAll(async () => {
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(() => {
resetToken()
})
describe('input not valid', () => {
it('throws error when contribution does not exist', async () => {
await expect(
mutate({
mutation: createContributionMessage,
variables: {
contributionId: -1,
message: 'Test',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
'ContributionMessage was not successful: Error: Contribution not found',
),
],
}),
)
})
it('throws error when other user tries to send createContributionMessage', async () => {
await query({
query: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await expect(
mutate({
mutation: createContributionMessage,
variables: {
contributionId: result.data.createContribution.id,
message: 'Test',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
'ContributionMessage was not successful: Error: Can not send message to contribution of another user',
),
],
}),
)
})
})
describe('valid input', () => {
beforeAll(async () => {
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
it('creates ContributionMessage', async () => {
await expect(
mutate({
mutation: createContributionMessage,
variables: {
contributionId: result.data.createContribution.id,
message: 'User Test',
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
createContributionMessage: expect.objectContaining({
id: expect.any(Number),
message: 'User Test',
type: 'DIALOG',
userFirstName: 'Bibi',
userLastName: 'Bloxberg',
}),
},
}),
)
})
})
})
})
describe('listContributionMessages', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
mutate({
mutation: listContributionMessages,
variables: { contributionId: 1 },
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
beforeAll(async () => {
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(() => {
resetToken()
})
it('returns a list of contributionmessages', async () => {
await expect(
mutate({
mutation: listContributionMessages,
variables: { contributionId: result.data.createContribution.id },
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listContributionMessages: {
count: 2,
messages: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
message: 'Admin Test',
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
}),
expect.objectContaining({
id: expect.any(Number),
message: 'User Test',
type: 'DIALOG',
userFirstName: 'Bibi',
userLastName: 'Bloxberg',
}),
]),
},
},
}),
)
})
})
})
})

View File

@ -0,0 +1,85 @@
import { backendLogger as logger } from '@/server/logger'
import { RIGHTS } from '@/auth/RIGHTS'
import { Context, getUser } from '@/server/context'
import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage'
import { Arg, Args, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql'
import ContributionMessageArgs from '@arg/ContributionMessageArgs'
import { Contribution } from '@entity/Contribution'
import { ContributionMessageType } from '@enum/MessageType'
import { ContributionStatus } from '@enum/ContributionStatus'
import { getConnection } from '@dbTools/typeorm'
import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage'
import Paginated from '@arg/Paginated'
import { Order } from '@enum/Order'
@Resolver()
export class ContributionMessageResolver {
@Authorized([RIGHTS.CREATE_CONTRIBUTION_MESSAGE])
@Mutation(() => ContributionMessage)
async createContributionMessage(
@Args() { contributionId, message }: ContributionMessageArgs,
@Ctx() context: Context,
): Promise<ContributionMessage> {
const user = getUser(context)
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('REPEATABLE READ')
const contributionMessage = DbContributionMessage.create()
try {
const contribution = await Contribution.findOne({ id: contributionId })
if (!contribution) {
throw new Error('Contribution not found')
}
if (contribution.userId !== user.id) {
throw new Error('Can not send message to contribution of another user')
}
contributionMessage.contributionId = contributionId
contributionMessage.createdAt = new Date()
contributionMessage.message = message
contributionMessage.userId = user.id
contributionMessage.type = ContributionMessageType.DIALOG
contributionMessage.isModerator = false
await queryRunner.manager.insert(DbContributionMessage, contributionMessage)
if (contribution.contributionStatus === ContributionStatus.IN_PROGRESS) {
contribution.contributionStatus = ContributionStatus.PENDING
await queryRunner.manager.update(Contribution, { id: contributionId }, contribution)
}
await queryRunner.commitTransaction()
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`ContributionMessage was not successful: ${e}`)
throw new Error(`ContributionMessage was not successful: ${e}`)
} finally {
await queryRunner.release()
}
return new ContributionMessage(contributionMessage, user)
}
@Authorized([RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES])
@Query(() => ContributionMessageListResult)
async listContributionMessages(
@Arg('contributionId') contributionId: number,
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
): Promise<ContributionMessageListResult> {
const [contributionMessages, count] = await getConnection()
.createQueryBuilder()
.select('cm')
.from(DbContributionMessage, 'cm')
.leftJoinAndSelect('cm.user', 'u')
.where({ contributionId: contributionId })
.orderBy('cm.createdAt', order)
.limit(pageSize)
.offset((currentPage - 1) * pageSize)
.getManyAndCount()
return {
count,
messages: contributionMessages.map(
(message) => new ContributionMessage(message, message.user),
),
}
}
}

View File

@ -11,7 +11,6 @@ import { ContributionType } from '@enum/ContributionType'
import { ContributionStatus } from '@enum/ContributionStatus' import { ContributionStatus } from '@enum/ContributionStatus'
import { Contribution, ContributionListResult } from '@model/Contribution' import { Contribution, ContributionListResult } from '@model/Contribution'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { User } from '@model/User'
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'
@ -90,19 +89,23 @@ export class ContributionResolver {
userId: number userId: number
confirmedBy?: FindOperator<number> | null confirmedBy?: FindOperator<number> | null
} = { userId: user.id } } = { userId: user.id }
if (filterConfirmed) where.confirmedBy = IsNull() if (filterConfirmed) where.confirmedBy = IsNull()
const [contributions, count] = await dbContribution.findAndCount({
where, const [contributions, count] = await getConnection()
order: { .createQueryBuilder()
createdAt: order, .select('c')
}, .from(dbContribution, 'c')
withDeleted: true, .leftJoinAndSelect('c.messages', 'm')
skip: (currentPage - 1) * pageSize, .where(where)
take: pageSize, .orderBy('c.createdAt', order)
}) .limit(pageSize)
.offset((currentPage - 1) * pageSize)
.getManyAndCount()
return new ContributionListResult( return new ContributionListResult(
count, count,
contributions.map((contribution) => new Contribution(contribution, new User(user))), contributions.map((contribution) => new Contribution(contribution, user)),
) )
} }
@ -123,9 +126,7 @@ export class ContributionResolver {
.getManyAndCount() .getManyAndCount()
return new ContributionListResult( return new ContributionListResult(
count, count,
dbContributions.map( dbContributions.map((contribution) => new Contribution(contribution, contribution.user)),
(contribution) => new Contribution(contribution, new User(contribution.user)),
),
) )
} }

View File

@ -20,7 +20,7 @@ export class GdtResolver {
try { try {
const resultGDT = await apiGet( const resultGDT = await apiGet(
`${CONFIG.GDT_API_URL}/GdtEntries/listPerEmailApi/${userEntity.email}/${currentPage}/${pageSize}/${order}`, `${CONFIG.GDT_API_URL}/GdtEntries/listPerEmailApi/${userEntity.emailContact.email}/${currentPage}/${pageSize}/${order}`,
) )
if (!resultGDT.success) { if (!resultGDT.success) {
throw new Error(resultGDT.data) throw new Error(resultGDT.data)
@ -37,7 +37,7 @@ export class GdtResolver {
const user = getUser(context) const user = getUser(context)
try { try {
const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, { const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, {
email: user.email, email: user.emailContact.email,
}) })
if (!resultGDTSum.success) { if (!resultGDTSum.success) {
throw new Error('Call not successful') throw new Error('Call not successful')

View File

@ -7,49 +7,48 @@ import { getConnection } from '@dbTools/typeorm'
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import { calculateDecay } from '@/util/decay' import { calculateDecay } from '@/util/decay'
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
@Resolver() @Resolver()
export class StatisticsResolver { export class StatisticsResolver {
@Authorized([RIGHTS.COMMUNITY_STATISTICS]) @Authorized([RIGHTS.COMMUNITY_STATISTICS])
@Query(() => CommunityStatistics) @Query(() => CommunityStatistics)
async communityStatistics(): Promise<CommunityStatistics> { async communityStatistics(): Promise<CommunityStatistics> {
const allUsers = await DbUser.find({ withDeleted: true }) const allUsers = await DbUser.count({ withDeleted: true })
const totalUsers = await DbUser.count()
let totalUsers = 0 const deletedUsers = allUsers - totalUsers
let activeUsers = 0
let deletedUsers = 0
let totalGradidoAvailable: Decimal = new Decimal(0) let totalGradidoAvailable: Decimal = new Decimal(0)
let totalGradidoUnbookedDecayed: Decimal = new Decimal(0) let totalGradidoUnbookedDecayed: Decimal = new Decimal(0)
const receivedCallDate = new Date() const receivedCallDate = new Date()
for (let i = 0; i < allUsers.length; i++) {
if (allUsers[i].deletedAt) {
deletedUsers++
} else {
totalUsers++
const lastTransaction = await DbTransaction.findOne({
where: { userId: allUsers[i].id },
order: { balanceDate: 'DESC' },
})
if (lastTransaction) {
activeUsers++
const decay = calculateDecay(
lastTransaction.balance,
lastTransaction.balanceDate,
receivedCallDate,
)
if (decay) {
totalGradidoAvailable = totalGradidoAvailable.plus(decay.balance.toString())
totalGradidoUnbookedDecayed = totalGradidoUnbookedDecayed.plus(decay.decay.toString())
}
}
}
}
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect() await queryRunner.connect()
const lastUserTransactions = await queryRunner.manager
.createQueryBuilder(DbUser, 'user')
.select('transaction.balance', 'balance')
.addSelect('transaction.balance_date', 'balanceDate')
.innerJoin(DbTransaction, 'transaction', 'user.id = transaction.user_id')
.where(
`transaction.balance_date = (SELECT MAX(t.balance_date) FROM transactions AS t WHERE t.user_id = user.id)`,
)
.orderBy('transaction.balance_date', 'DESC')
.addOrderBy('transaction.id', 'DESC')
.getRawMany()
const activeUsers = lastUserTransactions.length
lastUserTransactions.forEach(({ balance, balanceDate }) => {
const decay = calculateDecay(new Decimal(balance), new Date(balanceDate), receivedCallDate)
if (decay) {
totalGradidoAvailable = totalGradidoAvailable.plus(decay.balance.toString())
totalGradidoUnbookedDecayed = totalGradidoUnbookedDecayed.plus(decay.decay.toString())
}
})
const { totalGradidoCreated } = await queryRunner.manager const { totalGradidoCreated } = await queryRunner.manager
.createQueryBuilder() .createQueryBuilder()
.select('SUM(transaction.amount) AS totalGradidoCreated') .select('SUM(transaction.amount) AS totalGradidoCreated')

View File

@ -178,7 +178,7 @@ export class TransactionLinkResolver {
logger.info('redeem contribution link...') logger.info('redeem contribution link...')
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect() await queryRunner.connect()
await queryRunner.startTransaction('SERIALIZABLE') await queryRunner.startTransaction('REPEATABLE READ')
try { try {
const contributionLink = await queryRunner.manager const contributionLink = await queryRunner.manager
.createQueryBuilder() .createQueryBuilder()
@ -283,7 +283,10 @@ export class TransactionLinkResolver {
return true return true
} else { } else {
const transactionLink = await dbTransactionLink.findOneOrFail({ code }) const transactionLink = await dbTransactionLink.findOneOrFail({ code })
const linkedUser = await dbUser.findOneOrFail({ id: transactionLink.userId }) const linkedUser = await dbUser.findOneOrFail(
{ id: transactionLink.userId },
{ relations: ['emailContact'] },
)
if (user.id === linkedUser.id) { if (user.id === linkedUser.id) {
throw new Error('Cannot redeem own transaction link.') throw new Error('Cannot redeem own transaction link.')

View File

@ -35,6 +35,8 @@ import Decimal from 'decimal.js-light'
import { BalanceResolver } from './BalanceResolver' 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 { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed'
export const executeTransaction = async ( export const executeTransaction = async (
amount: Decimal, amount: Decimal,
@ -78,7 +80,7 @@ export const executeTransaction = async (
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect() await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED') await queryRunner.startTransaction('REPEATABLE READ')
logger.debug(`open Transaction to write...`) logger.debug(`open Transaction to write...`)
try { try {
// transaction // transaction
@ -148,12 +150,24 @@ export const executeTransaction = async (
senderLastName: sender.lastName, senderLastName: sender.lastName,
recipientFirstName: recipient.firstName, recipientFirstName: recipient.firstName,
recipientLastName: recipient.lastName, recipientLastName: recipient.lastName,
email: recipient.email, email: recipient.emailContact.email,
senderEmail: sender.email, senderEmail: sender.emailContact.email,
amount, amount,
memo,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
}) })
if (transactionLink) {
await sendTransactionLinkRedeemedEmail({
senderFirstName: recipient.firstName,
senderLastName: recipient.lastName,
recipientFirstName: sender.firstName,
recipientLastName: sender.lastName,
email: sender.emailContact.email,
senderEmail: recipient.emailContact.email,
amount,
memo,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
})
}
logger.info(`finished executeTransaction successfully`) logger.info(`finished executeTransaction successfully`)
return true return true
} }
@ -171,7 +185,7 @@ export class TransactionResolver {
const user = getUser(context) const user = getUser(context)
logger.addContext('user', user.id) logger.addContext('user', user.id)
logger.info(`transactionList(user=${user.firstName}.${user.lastName}, ${user.email})`) logger.info(`transactionList(user=${user.firstName}.${user.lastName}, ${user.emailId})`)
// find current balance // find current balance
const lastTransaction = await dbTransaction.findOne( const lastTransaction = await dbTransaction.findOne(
@ -293,16 +307,25 @@ export class TransactionResolver {
} }
// validate recipient user // validate recipient user
const recipientUser = await dbUser.findOne({ email: email }, { withDeleted: true }) const recipientUser = await findUserByEmail(email)
/*
const emailContact = await UserContact.findOne({ email }, { withDeleted: true })
if (!emailContact) {
logger.error(`Could not find UserContact with email: ${email}`)
throw new Error(`Could not find UserContact with email: ${email}`)
}
*/
// const recipientUser = await dbUser.findOne({ id: emailContact.userId })
if (!recipientUser) { if (!recipientUser) {
logger.error(`recipient not known: email=${email}`) logger.error(`unknown recipient to UserContact: email=${email}`)
throw new Error('recipient not known') throw new Error('unknown recipient')
} }
if (recipientUser.deletedAt) { if (recipientUser.deletedAt) {
logger.error(`The recipient account was deleted: recipientUser=${recipientUser}`) logger.error(`The recipient account was deleted: recipientUser=${recipientUser}`)
throw new Error('The recipient account was deleted') throw new Error('The recipient account was deleted')
} }
if (!recipientUser.emailChecked) { const emailContact = recipientUser.emailContact
if (!emailContact.emailChecked) {
logger.error(`The recipient account is not activated: recipientUser=${recipientUser}`) logger.error(`The recipient account is not activated: recipientUser=${recipientUser}`)
throw new Error('The recipient account is not activated') throw new Error('The recipient account is not activated')
} }

View File

@ -1,13 +1,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { testEnvironment, headerPushMock, resetToken, cleanDB, resetEntity } from '@test/helpers' import { testEnvironment, headerPushMock, resetToken, cleanDB } from '@test/helpers'
import { userFactory } from '@/seeds/factory/user' import { userFactory } from '@/seeds/factory/user'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { createUser, setPassword, forgotPassword, updateUserInfos } from '@/seeds/graphql/mutations' import {
createUser,
setPassword,
forgotPassword,
updateUserInfos,
createContribution,
confirmContribution,
} from '@/seeds/graphql/mutations'
import { login, logout, verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries' import { login, logout, verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries'
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User } from '@entity/User' import { User } from '@entity/User'
import CONFIG from '@/config' import CONFIG from '@/config'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
@ -15,13 +21,19 @@ import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegi
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail' import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
import { printTimeDuration, activationLink } from './UserResolver' import { printTimeDuration, activationLink } from './UserResolver'
import { contributionLinkFactory } from '@/seeds/factory/contributionLink' import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
// import { transactionLinkFactory } from '@/seeds/factory/transactionLink' import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
import { ContributionLink } from '@model/ContributionLink' import { ContributionLink } from '@model/ContributionLink'
// import { TransactionLink } from '@entity/TransactionLink' import { TransactionLink } from '@entity/TransactionLink'
import { EventProtocolType } from '@/event/EventProtocolType'
import { EventProtocol } from '@entity/EventProtocol'
import { logger } from '@test/testSetup' import { logger } from '@test/testSetup'
import { validate as validateUUID, version as versionUUID } from 'uuid' import { validate as validateUUID, version as versionUUID } from 'uuid'
import { peterLustig } from '@/seeds/users/peter-lustig' import { peterLustig } from '@/seeds/users/peter-lustig'
import { UserContact } from '@entity/UserContact'
import { OptInType } from '../enum/OptInType'
import { UserContactType } from '../enum/UserContactType'
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
// import { klicktippSignIn } from '@/apis/KlicktippController' // import { klicktippSignIn } from '@/apis/KlicktippController'
@ -82,7 +94,7 @@ describe('UserResolver', () => {
} }
let result: any let result: any
let emailOptIn: string let emailVerificationCode: string
let user: User[] let user: User[]
beforeAll(async () => { beforeAll(async () => {
@ -101,11 +113,11 @@ describe('UserResolver', () => {
}) })
describe('valid input data', () => { describe('valid input data', () => {
let loginEmailOptIn: LoginEmailOptIn[] // let loginEmailOptIn: LoginEmailOptIn[]
beforeAll(async () => { beforeAll(async () => {
user = await User.find() user = await User.find({ relations: ['emailContact'] })
loginEmailOptIn = await LoginEmailOptIn.find() // loginEmailOptIn = await LoginEmailOptIn.find()
emailOptIn = loginEmailOptIn[0].verificationCode.toString() emailVerificationCode = user[0].emailContact.emailVerificationCode.toString()
}) })
describe('filling all tables', () => { describe('filling all tables', () => {
@ -115,15 +127,16 @@ describe('UserResolver', () => {
id: expect.any(Number), id: expect.any(Number),
gradidoID: expect.any(String), gradidoID: expect.any(String),
alias: null, alias: null,
email: 'peter@lustig.de', emailContact: expect.any(UserContact), // 'peter@lustig.de',
emailId: expect.any(Number),
firstName: 'Peter', firstName: 'Peter',
lastName: 'Lustig', lastName: 'Lustig',
password: '0', password: '0',
pubKey: null, pubKey: null,
privKey: null, privKey: null,
emailHash: expect.any(Buffer), // emailHash: expect.any(Buffer),
createdAt: expect.any(Date), createdAt: expect.any(Date),
emailChecked: false, // emailChecked: false,
passphrase: expect.any(String), passphrase: expect.any(String),
language: 'de', language: 'de',
isAdmin: null, isAdmin: null,
@ -139,18 +152,21 @@ describe('UserResolver', () => {
expect(verUUID).toEqual(4) expect(verUUID).toEqual(4)
}) })
it('creates an email optin', () => { it('creates an email contact', () => {
expect(loginEmailOptIn).toEqual([ expect(user[0].emailContact).toEqual({
{ id: expect.any(Number),
id: expect.any(Number), type: UserContactType.USER_CONTACT_EMAIL,
userId: user[0].id, userId: user[0].id,
verificationCode: expect.any(String), email: 'peter@lustig.de',
emailOptInTypeId: 1, emailChecked: false,
createdAt: expect.any(Date), emailVerificationCode: expect.any(String),
resendCount: 0, emailOptInTypeId: OptInType.EMAIL_OPT_IN_REGISTER,
updatedAt: expect.any(Date), emailResendCount: 0,
}, phone: null,
]) createdAt: expect.any(Date),
deletedAt: null,
updatedAt: null,
})
}) })
}) })
}) })
@ -159,7 +175,7 @@ describe('UserResolver', () => {
it('sends an account activation email', () => { it('sends an account activation email', () => {
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace( const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g, /{optin}/g,
emailOptIn, emailVerificationCode,
).replace(/{code}/g, '') ).replace(/{code}/g, '')
expect(sendAccountActivationEmail).toBeCalledWith({ expect(sendAccountActivationEmail).toBeCalledWith({
link: activationLink, link: activationLink,
@ -169,6 +185,15 @@ describe('UserResolver', () => {
duration: expect.any(String), duration: expect.any(String),
}) })
}) })
it('stores the send confirmation event in the database', () => {
expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.SEND_CONFIRMATION_EMAIL,
userId: user[0].id,
}),
)
})
}) })
describe('email already exists', () => { describe('email already exists', () => {
@ -208,13 +233,13 @@ describe('UserResolver', () => {
mutation: createUser, mutation: createUser,
variables: { ...variables, email: 'bibi@bloxberg.de', language: 'it' }, variables: { ...variables, email: 'bibi@bloxberg.de', language: 'it' },
}) })
await expect(User.find()).resolves.toEqual( await expect(
expect.arrayContaining([ UserContact.findOne({ email: 'bibi@bloxberg.de' }, { relations: ['user'] }),
expect.objectContaining({ ).resolves.toEqual(
email: 'bibi@bloxberg.de', expect.objectContaining({
language: 'de', email: 'bibi@bloxberg.de',
}), user: expect.objectContaining({ language: 'de' }),
]), }),
) )
}) })
}) })
@ -225,10 +250,12 @@ describe('UserResolver', () => {
mutation: createUser, mutation: createUser,
variables: { ...variables, email: 'raeuber@hotzenplotz.de', publisherId: undefined }, variables: { ...variables, email: 'raeuber@hotzenplotz.de', publisherId: undefined },
}) })
await expect(User.find()).resolves.toEqual( await expect(User.find({ relations: ['emailContact'] })).resolves.toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
email: 'raeuber@hotzenplotz.de', emailContact: expect.objectContaining({
email: 'raeuber@hotzenplotz.de',
}),
publisherId: null, publisherId: null,
}), }),
]), ]),
@ -237,37 +264,157 @@ describe('UserResolver', () => {
}) })
describe('redeem codes', () => { describe('redeem codes', () => {
let result: any
let link: ContributionLink
describe('contribution link', () => { describe('contribution link', () => {
let link: ContributionLink
beforeAll(async () => { beforeAll(async () => {
// activate account of admin Peter Lustig // activate account of admin Peter Lustig
await mutate({ await mutate({
mutation: setPassword, mutation: setPassword,
variables: { code: emailOptIn, password: 'Aa12345_' }, variables: { code: emailVerificationCode, password: 'Aa12345_' },
}) })
// make Peter Lustig Admin // make Peter Lustig Admin
const peter = await User.findOneOrFail({ id: user[0].id }) const peter = await User.findOneOrFail({ id: user[0].id })
peter.isAdmin = new Date() peter.isAdmin = new Date()
await peter.save() await peter.save()
// date statement
const actualDate = new Date()
const futureDate = new Date() // Create a future day from the executed day
futureDate.setDate(futureDate.getDate() + 1)
// factory logs in as Peter Lustig // factory logs in as Peter Lustig
link = await contributionLinkFactory(testEnv, { link = await contributionLinkFactory(testEnv, {
name: 'Dokumenta 2022', name: 'Dokumenta 2022',
memo: 'Vielen Dank für deinen Besuch bei der Dokumenta 2022', memo: 'Vielen Dank für deinen Besuch bei der Dokumenta 2022',
amount: 200, amount: 200,
validFrom: new Date(2022, 5, 18), validFrom: actualDate,
validTo: new Date(2022, 8, 25), validTo: futureDate,
}) })
resetToken() resetToken()
await mutate({ result = await mutate({
mutation: createUser, mutation: createUser,
variables: { ...variables, email: 'ein@besucher.de', redeemCode: 'CL-' + link.code }, variables: { ...variables, email: 'ein@besucher.de', redeemCode: 'CL-' + link.code },
}) })
}) })
afterAll(async () => {
await cleanDB()
})
it('sets the contribution link id', async () => { it('sets the contribution link id', async () => {
await expect(User.findOne({ email: 'ein@besucher.de' })).resolves.toEqual( await expect(
UserContact.findOne({ email: 'ein@besucher.de' }, { relations: ['user'] }),
).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
contributionLinkId: link.id, user: expect.objectContaining({
contributionLinkId: link.id,
}),
}),
)
})
it('stores the account activated event in the database', () => {
expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.ACTIVATE_ACCOUNT,
userId: user[0].id,
}),
)
})
it('stores the redeem register event in the database', () => {
expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.REDEEM_REGISTER,
userId: result.data.createUser.id,
contributionId: link.id,
}),
)
})
})
describe('transaction link', () => {
let contribution: any
let bob: any
let transactionLink: TransactionLink
let newUser: any
const bobData = {
email: 'bob@baumeister.de',
password: 'Aa12345_',
publisherId: 1234,
}
const peterData = {
email: 'peter@lustig.de',
password: 'Aa12345_',
publisherId: 1234,
}
beforeAll(async () => {
await userFactory(testEnv, peterLustig)
await userFactory(testEnv, bobBaumeister)
await query({ query: login, variables: bobData })
// create contribution as user bob
contribution = await mutate({
mutation: createContribution,
variables: { amount: 1000, memo: 'testing', creationDate: new Date().toISOString() },
})
// login as admin
await query({ query: login, variables: peterData })
// confirm the contribution
contribution = await mutate({
mutation: confirmContribution,
variables: { id: contribution.data.createContribution.id },
})
// login as user bob
bob = await query({ query: login, variables: bobData })
// create transaction link
await transactionLinkFactory(testEnv, {
email: 'bob@baumeister.de',
amount: 19.99,
memo: `testing transaction link`,
})
transactionLink = await TransactionLink.findOneOrFail()
resetToken()
// create new user using transaction link of bob
newUser = await mutate({
mutation: createUser,
variables: {
...variables,
email: 'which@ever.de',
redeemCode: transactionLink.code,
},
})
})
it('sets the referrer id to bob baumeister id', async () => {
await expect(
UserContact.findOne({ email: 'which@ever.de' }, { relations: ['user'] }),
).resolves.toEqual(
expect.objectContaining({
user: expect.objectContaining({ referrerId: bob.data.login.id }),
}),
)
})
it('stores the redeem register event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.REDEEM_REGISTER,
userId: newUser.data.createUser.id,
}), }),
) )
}) })
@ -282,7 +429,7 @@ describe('UserResolver', () => {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: 19.99, amount: 19.99,
memo: `Kein Trick, keine Zauberrei, memo: `Kein Trick, keine Zauberrei,
bei Gradidio sei dabei!`, bei Gradidio sei dabei!`,
}) })
const transactionLink = await TransactionLink.findOneOrFail() const transactionLink = await TransactionLink.findOneOrFail()
resetToken() resetToken()
@ -291,14 +438,14 @@ bei Gradidio sei dabei!`,
variables: { ...variables, email: 'neuer@user.de', redeemCode: transactionLink.code }, variables: { ...variables, email: 'neuer@user.de', redeemCode: transactionLink.code },
}) })
}) })
it('sets the referrer id to Peter Lustigs id', async () => { it('sets the referrer id to Peter Lustigs id', async () => {
await expect(User.findOne({ email: 'neuer@user.de' })).resolves.toEqual(expect.objectContaining({ await expect(User.findOne({ email: 'neuer@user.de' })).resolves.toEqual(expect.objectContaining({
referrerId: user[0].id, referrerId: user[0].id,
})) }))
}) })
}) })
*/ */
}) })
}) })
@ -313,20 +460,23 @@ bei Gradidio sei dabei!`,
} }
let result: any let result: any
let emailOptIn: string let emailVerificationCode: string
describe('valid optin code and valid password', () => { describe('valid optin code and valid password', () => {
let newUser: any let newUser: User
beforeAll(async () => { beforeAll(async () => {
await mutate({ mutation: createUser, variables: createUserVariables }) await mutate({ mutation: createUser, variables: createUserVariables })
const loginEmailOptIn = await LoginEmailOptIn.find() const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email })
emailOptIn = loginEmailOptIn[0].verificationCode.toString() emailVerificationCode = emailContact.emailVerificationCode.toString()
result = await mutate({ result = await mutate({
mutation: setPassword, mutation: setPassword,
variables: { code: emailOptIn, password: 'Aa12345_' }, variables: { code: emailVerificationCode, password: 'Aa12345_' },
}) })
newUser = await User.find() newUser = await User.findOneOrFail(
{ id: emailContact.userId },
{ relations: ['emailContact'] },
)
}) })
afterAll(async () => { afterAll(async () => {
@ -334,11 +484,11 @@ bei Gradidio sei dabei!`,
}) })
it('sets email checked to true', () => { it('sets email checked to true', () => {
expect(newUser[0].emailChecked).toBeTruthy() expect(newUser.emailContact.emailChecked).toBeTruthy()
}) })
it('updates the password', () => { it('updates the password', () => {
expect(newUser[0].password).toEqual('3917921995996627700') expect(newUser.password).toEqual('3917921995996627700')
}) })
/* /*
@ -360,11 +510,11 @@ bei Gradidio sei dabei!`,
describe('no valid password', () => { describe('no valid password', () => {
beforeAll(async () => { beforeAll(async () => {
await mutate({ mutation: createUser, variables: createUserVariables }) await mutate({ mutation: createUser, variables: createUserVariables })
const loginEmailOptIn = await LoginEmailOptIn.find() const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email })
emailOptIn = loginEmailOptIn[0].verificationCode.toString() emailVerificationCode = emailContact.emailVerificationCode.toString()
result = await mutate({ result = await mutate({
mutation: setPassword, mutation: setPassword,
variables: { code: emailOptIn, password: 'not-valid' }, variables: { code: emailVerificationCode, password: 'not-valid' },
}) })
}) })
@ -383,6 +533,10 @@ bei Gradidio sei dabei!`,
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Password entered is lexically invalid')
})
}) })
describe('no valid optin code', () => { describe('no valid optin code', () => {
@ -405,6 +559,10 @@ bei Gradidio sei dabei!`,
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('Could not login with emailVerificationCode')
})
}) })
}) })
@ -423,6 +581,7 @@ bei Gradidio sei dabei!`,
describe('no users in database', () => { describe('no users in database', () => {
beforeAll(async () => { beforeAll(async () => {
jest.clearAllMocks()
result = await query({ query: login, variables }) result = await query({ query: login, variables })
}) })
@ -433,6 +592,12 @@ bei Gradidio sei dabei!`,
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'UserContact with email=bibi@bloxberg.de does not exists',
)
})
}) })
describe('user is in database and correct login data', () => { describe('user is in database and correct login data', () => {
@ -475,6 +640,7 @@ bei Gradidio sei dabei!`,
describe('user is in database and wrong password', () => { describe('user is in database and wrong password', () => {
beforeAll(async () => { beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg) await userFactory(testEnv, bibiBloxberg)
result = await query({ query: login, variables: { ...variables, password: 'wrong' } })
}) })
afterAll(async () => { afterAll(async () => {
@ -482,14 +648,16 @@ bei Gradidio sei dabei!`,
}) })
it('returns an error', () => { it('returns an error', () => {
expect( expect(result).toEqual(
query({ query: login, variables: { ...variables, password: 'wrong' } }),
).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [new GraphQLError('No user with this credentials')], errors: [new GraphQLError('No user with this credentials')],
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The User has no valid credentials.')
})
}) })
}) })
@ -562,6 +730,8 @@ bei Gradidio sei dabei!`,
}) })
describe('authenticated', () => { describe('authenticated', () => {
let user: User[]
const variables = { const variables = {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
password: 'Aa12345_', password: 'Aa12345_',
@ -569,6 +739,7 @@ bei Gradidio sei dabei!`,
beforeAll(async () => { beforeAll(async () => {
await query({ query: login, variables }) await query({ query: login, variables })
user = await User.find()
}) })
afterAll(() => { afterAll(() => {
@ -595,52 +766,83 @@ bei Gradidio sei dabei!`,
}), }),
) )
}) })
it('stores the login event in the database', () => {
expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.LOGIN,
userId: user[0].id,
}),
)
})
}) })
}) })
}) })
describe('forgotPassword', () => { describe('forgotPassword', () => {
const variables = { email: 'bibi@bloxberg.de' } const variables = { email: 'bibi@bloxberg.de' }
const emailCodeRequestTime = CONFIG.EMAIL_CODE_REQUEST_TIME
describe('user is not in DB', () => { describe('user is not in DB', () => {
it('returns true', async () => { describe('duration not expired', () => {
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual( it('returns true', async () => {
expect.objectContaining({ await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
data: { expect.objectContaining({
forgotPassword: true, data: {
}, forgotPassword: true,
}), },
) }),
)
})
}) })
}) })
describe('user exists in DB', () => { describe('user exists in DB', () => {
let result: any let emailContact: UserContact
let loginEmailOptIn: LoginEmailOptIn[]
beforeAll(async () => { beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg) await userFactory(testEnv, bibiBloxberg)
await resetEntity(LoginEmailOptIn) // await resetEntity(LoginEmailOptIn)
result = await mutate({ mutation: forgotPassword, variables }) emailContact = await UserContact.findOneOrFail(variables)
loginEmailOptIn = await LoginEmailOptIn.find()
}) })
afterAll(async () => { afterAll(async () => {
await cleanDB() await cleanDB()
CONFIG.EMAIL_CODE_REQUEST_TIME = emailCodeRequestTime
}) })
it('returns true', async () => { describe('duration not expired', () => {
await expect(result).toEqual( it('returns true', async () => {
expect.objectContaining({ await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
data: { expect.objectContaining({
forgotPassword: true, errors: [
}, new GraphQLError(
}), `email already sent less than ${printTimeDuration(
) CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
),
],
}),
)
})
})
describe('duration reset to 0', () => {
it('returns true', async () => {
CONFIG.EMAIL_CODE_REQUEST_TIME = 0
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
expect.objectContaining({
data: {
forgotPassword: true,
},
}),
)
})
}) })
it('sends reset password email', () => { it('sends reset password email', () => {
expect(sendResetPasswordEmail).toBeCalledWith({ expect(sendResetPasswordEmail).toBeCalledWith({
link: activationLink(loginEmailOptIn[0]), link: activationLink(emailContact.emailVerificationCode),
firstName: 'Bibi', firstName: 'Bibi',
lastName: 'Bloxberg', lastName: 'Bloxberg',
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
@ -650,22 +852,27 @@ bei Gradidio sei dabei!`,
describe('request reset password again', () => { describe('request reset password again', () => {
it('thows an error', async () => { it('thows an error', async () => {
CONFIG.EMAIL_CODE_REQUEST_TIME = emailCodeRequestTime
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual( await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [new GraphQLError('email already sent less than 10 minutes minutes ago')], errors: [new GraphQLError('email already sent less than 10 minutes minutes ago')],
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`email already sent less than 10 minutes minutes ago`)
})
}) })
}) })
}) })
describe('queryOptIn', () => { describe('queryOptIn', () => {
let loginEmailOptIn: LoginEmailOptIn[] let emailContact: UserContact
beforeAll(async () => { beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg) await userFactory(testEnv, bibiBloxberg)
loginEmailOptIn = await LoginEmailOptIn.find() emailContact = await UserContact.findOneOrFail({ email: bibiBloxberg.email })
}) })
afterAll(async () => { afterAll(async () => {
@ -680,8 +887,8 @@ bei Gradidio sei dabei!`,
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [
// keep Whitspace in error message! // keep Whitspace in error message!
new GraphQLError(`Could not find any entity of type "LoginEmailOptIn" matching: { new GraphQLError(`Could not find any entity of type "UserContact" matching: {
"verificationCode": "not-valid" "emailVerificationCode": "not-valid"
}`), }`),
], ],
}), }),
@ -694,7 +901,7 @@ bei Gradidio sei dabei!`,
await expect( await expect(
query({ query({
query: queryOptIn, query: queryOptIn,
variables: { optIn: loginEmailOptIn[0].verificationCode.toString() }, variables: { optIn: emailContact.emailVerificationCode.toString() },
}), }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
@ -766,7 +973,7 @@ bei Gradidio sei dabei!`,
}) })
describe('language is not valid', () => { describe('language is not valid', () => {
it('thows an error', async () => { it('throws an error', async () => {
await expect( await expect(
mutate({ mutate({
mutation: updateUserInfos, mutation: updateUserInfos,
@ -780,6 +987,10 @@ bei Gradidio sei dabei!`,
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`"not-valid" isn't a valid language`)
})
}) })
describe('password', () => { describe('password', () => {
@ -799,6 +1010,10 @@ bei Gradidio sei dabei!`,
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`Old password is invalid`)
})
}) })
describe('invalid new password', () => { describe('invalid new password', () => {
@ -821,6 +1036,10 @@ bei Gradidio sei dabei!`,
}), }),
) )
}) })
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('newPassword does not fullfil the rules')
})
}) })
describe('correct old and new password', () => { describe('correct old and new password', () => {
@ -840,7 +1059,7 @@ bei Gradidio sei dabei!`,
) )
}) })
it('can login wtih new password', async () => { it('can login with new password', async () => {
await expect( await expect(
query({ query({
query: login, query: login,
@ -860,7 +1079,7 @@ bei Gradidio sei dabei!`,
) )
}) })
it('cannot login wtih old password', async () => { it('cannot login with old password', async () => {
await expect( await expect(
query({ query({
query: login, query: login,
@ -875,6 +1094,10 @@ bei Gradidio sei dabei!`,
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The User has no valid credentials.')
})
}) })
}) })
}) })

View File

@ -1,12 +1,12 @@
import fs from 'fs' import fs from 'fs'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context' import { Context, getUser } from '@/server/context'
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql'
import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm' import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm'
import CONFIG from '@/config' import CONFIG from '@/config'
import { User } from '@model/User' import { User } from '@model/User'
import { User as DbUser } from '@entity/User' import { User as DbUser } from '@entity/User'
import { UserContact as DbUserContact } from '@entity/UserContact'
import { communityDbUser } from '@/util/communityUser' import { communityDbUser } from '@/util/communityUser'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { ContributionLink as dbContributionLink } from '@entity/ContributionLink' import { ContributionLink as dbContributionLink } from '@entity/ContributionLink'
@ -16,7 +16,6 @@ import UnsecureLoginArgs from '@arg/UnsecureLoginArgs'
import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs' import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware' import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
import { OptInType } from '@enum/OptInType' import { OptInType } from '@enum/OptInType'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail' import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail' import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail'
@ -29,9 +28,12 @@ import {
EventLogin, EventLogin,
EventRedeemRegister, EventRedeemRegister,
EventRegister, EventRegister,
EventSendAccountMultiRegistrationEmail,
EventSendConfirmationEmail, EventSendConfirmationEmail,
EventActivateAccount,
} from '@/event/Event' } from '@/event/Event'
import { getUserCreation } from './util/creations' import { getUserCreation } from './util/creations'
import { UserContactType } from '../enum/UserContactType'
import { UserRepository } from '@/typeorm/repository/User' import { UserRepository } from '@/typeorm/repository/User'
import { SearchAdminUsersResult } from '@model/AdminUser' import { SearchAdminUsersResult } from '@model/AdminUser'
import Paginated from '@arg/Paginated' import Paginated from '@arg/Paginated'
@ -146,6 +148,7 @@ const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[
return [encryptionKeyHash, encryptionKey] return [encryptionKeyHash, encryptionKey]
} }
/*
const getEmailHash = (email: string): Buffer => { const getEmailHash = (email: string): Buffer => {
logger.trace('getEmailHash...') logger.trace('getEmailHash...')
const emailHash = Buffer.alloc(sodium.crypto_generichash_BYTES) const emailHash = Buffer.alloc(sodium.crypto_generichash_BYTES)
@ -153,6 +156,7 @@ const getEmailHash = (email: string): Buffer => {
logger.debug(`getEmailHash...successful: ${emailHash}`) logger.debug(`getEmailHash...successful: ${emailHash}`)
return emailHash return emailHash
} }
*/
const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => { const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => {
logger.trace('SecretKeyCryptographyEncrypt...') logger.trace('SecretKeyCryptographyEncrypt...')
@ -177,6 +181,19 @@ const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: B
return message return message
} }
const newEmailContact = (email: string, userId: number): DbUserContact => {
logger.trace(`newEmailContact...`)
const emailContact = new DbUserContact()
emailContact.email = email
emailContact.userId = userId
emailContact.type = UserContactType.USER_CONTACT_EMAIL
emailContact.emailChecked = false
emailContact.emailOptInTypeId = OptInType.EMAIL_OPT_IN_REGISTER
emailContact.emailVerificationCode = random(64)
logger.debug(`newEmailContact...successful: ${emailContact}`)
return emailContact
}
/*
const newEmailOptIn = (userId: number): LoginEmailOptIn => { const newEmailOptIn = (userId: number): LoginEmailOptIn => {
logger.trace('newEmailOptIn...') logger.trace('newEmailOptIn...')
const emailOptIn = new LoginEmailOptIn() const emailOptIn = new LoginEmailOptIn()
@ -186,7 +203,8 @@ const newEmailOptIn = (userId: number): LoginEmailOptIn => {
logger.debug(`newEmailOptIn...successful: ${emailOptIn}`) logger.debug(`newEmailOptIn...successful: ${emailOptIn}`)
return emailOptIn return emailOptIn
} }
*/
/*
// needed by AdminResolver // needed by AdminResolver
// checks if given code exists and can be resent // checks if given code exists and can be resent
// if optIn does not exits, it is created // if optIn does not exits, it is created
@ -226,10 +244,44 @@ export const checkOptInCode = async (
logger.debug(`checkOptInCode...successful: ${optInCode} for userid=${user.id}`) logger.debug(`checkOptInCode...successful: ${optInCode} for userid=${user.id}`)
return optInCode return optInCode
} }
*/
export const checkEmailVerificationCode = async (
emailContact: DbUserContact,
optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
): Promise<DbUserContact> => {
logger.info(`checkEmailVerificationCode... ${emailContact}`)
if (emailContact.updatedAt) {
if (!canEmailResend(emailContact.updatedAt)) {
logger.error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)
throw new Error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)
}
emailContact.updatedAt = new Date()
emailContact.emailResendCount++
} else {
logger.trace('create new EmailVerificationCode for userId=' + emailContact.userId)
emailContact.emailChecked = false
emailContact.emailVerificationCode = random(64)
}
emailContact.emailOptInTypeId = optInType
await DbUserContact.save(emailContact).catch(() => {
logger.error('Unable to save email verification code= ' + emailContact)
throw new Error('Unable to save email verification code.')
})
logger.debug(`checkEmailVerificationCode...successful: ${emailContact}`)
return emailContact
}
export const activationLink = (optInCode: LoginEmailOptIn): string => { export const activationLink = (verificationCode: BigInt): string => {
logger.debug(`activationLink(${LoginEmailOptIn})...`) logger.debug(`activationLink(${verificationCode})...`)
return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, optInCode.verificationCode.toString()) return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, verificationCode.toString())
} }
const newGradidoID = async (): Promise<string> => { const newGradidoID = async (): Promise<string> => {
@ -272,15 +324,12 @@ export class UserResolver {
): Promise<User> { ): Promise<User> {
logger.info(`login with ${email}, ***, ${publisherId} ...`) logger.info(`login with ${email}, ***, ${publisherId} ...`)
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
const dbUser = await DbUser.findOneOrFail({ email }, { withDeleted: true }).catch(() => { const dbUser = await findUserByEmail(email)
logger.error(`User with email=${email} does not exists`)
throw new Error('No user with this credentials')
})
if (dbUser.deletedAt) { if (dbUser.deletedAt) {
logger.error('The User was permanently deleted in database.') logger.error('The User was permanently deleted in database.')
throw new Error('This user was permanently deleted. Contact support for questions.') throw new Error('This user was permanently deleted. Contact support for questions.')
} }
if (!dbUser.emailChecked) { if (!dbUser.emailContact.emailChecked) {
logger.error('The Users email is not validate yet.') logger.error('The Users email is not validate yet.')
throw new Error('User email not validated') throw new Error('User email not validated')
} }
@ -305,7 +354,7 @@ export class UserResolver {
logger.debug('login credentials valid...') logger.debug('login credentials valid...')
const user = new User(dbUser, await getUserCreation(dbUser.id)) const user = new User(dbUser, await getUserCreation(dbUser.id))
logger.debug('user=' + user) logger.debug(`user= ${JSON.stringify(user, null, 2)}`)
// Elopage Status & Stored PublisherId // Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage({ ...context, user: dbUser }) user.hasElopage = await this.hasElopage({ ...context, user: dbUser })
@ -323,7 +372,7 @@ export class UserResolver {
const ev = new EventLogin() const ev = new EventLogin()
ev.userId = user.id ev.userId = user.id
eventProtocol.writeEvent(new Event().setEventLogin(ev)) eventProtocol.writeEvent(new Event().setEventLogin(ev))
logger.info('successful Login:' + user) logger.info(`successful Login: ${JSON.stringify(user, null, 2)}`)
return user return user
} }
@ -352,66 +401,72 @@ export class UserResolver {
) )
// TODO: wrong default value (should be null), how does graphql work here? Is it an required field? // TODO: wrong default value (should be null), how does graphql work here? Is it an required field?
// default int publisher_id = 0; // default int publisher_id = 0;
const event = new Event()
// Validate Language (no throw) // Validate Language (no throw)
if (!language || !isLanguage(language)) { if (!language || !isLanguage(language)) {
language = DEFAULT_LANGUAGE language = DEFAULT_LANGUAGE
} }
// Validate email unique // check if user with email still exists?
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
// TODO we cannot use repository.count(), since it does not allow to specify if you want to include the soft deletes if (await checkEmailExists(email)) {
const userFound = await DbUser.findOne({ email }, { withDeleted: true }) const foundUser = await findUserByEmail(email)
logger.info(`DbUser.findOne(email=${email}) = ${userFound}`) logger.info(`DbUser.findOne(email=${email}) = ${foundUser}`)
if (userFound) { if (foundUser) {
// ATTENTION: this logger-message will be exactly expected during tests // ATTENTION: this logger-message will be exactly expected during tests
logger.info(`User already exists with this email=${email}`) logger.info(`User already exists with this email=${email}`)
// TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent. // TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent.
const user = new User(communityDbUser) const user = new User(communityDbUser)
user.id = sodium.randombytes_random() % (2048 * 16) // TODO: for a better faking derive id from email so that it will be always the same id when the same email comes in? user.id = sodium.randombytes_random() % (2048 * 16) // TODO: for a better faking derive id from email so that it will be always the same id when the same email comes in?
user.gradidoID = uuidv4() user.gradidoID = uuidv4()
user.email = email user.email = email
user.firstName = firstName user.firstName = firstName
user.lastName = lastName user.lastName = lastName
user.language = language user.language = language
user.publisherId = publisherId user.publisherId = publisherId
logger.debug('partly faked user=' + user) logger.debug('partly faked user=' + user)
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountMultiRegistrationEmail({ const emailSent = await sendAccountMultiRegistrationEmail({
firstName, firstName,
lastName, lastName,
email, email,
}) })
logger.info(`sendAccountMultiRegistrationEmail of ${firstName}.${lastName} to ${email}`) const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail()
/* uncomment this, when you need the activation link on the console */ eventSendAccountMultiRegistrationEmail.userId = foundUser.id
// In case EMails are disabled log the activation link for the user eventProtocol.writeEvent(
if (!emailSent) { event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail),
logger.debug(`Email not send!`) )
logger.info(`sendAccountMultiRegistrationEmail of ${firstName}.${lastName} to ${email}`)
/* uncomment this, when you need the activation link on the console */
// In case EMails are disabled log the activation link for the user
if (!emailSent) {
logger.debug(`Email not send!`)
}
logger.info('createUser() faked and send multi registration mail...')
return user
} }
logger.info('createUser() faked and send multi registration mail...')
return user
} }
const passphrase = PassphraseGenerate() const passphrase = PassphraseGenerate()
// const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key // const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
// const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash // const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
// const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) // const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
const emailHash = getEmailHash(email) // const emailHash = getEmailHash(email)
const gradidoID = await newGradidoID() const gradidoID = await newGradidoID()
const eventRegister = new EventRegister() const eventRegister = new EventRegister()
const eventRedeemRegister = new EventRedeemRegister() const eventRedeemRegister = new EventRedeemRegister()
const eventSendConfirmEmail = new EventSendConfirmationEmail() const eventSendConfirmEmail = new EventSendConfirmationEmail()
const dbUser = new DbUser()
let dbUser = new DbUser()
dbUser.gradidoID = gradidoID dbUser.gradidoID = gradidoID
dbUser.email = email
dbUser.firstName = firstName dbUser.firstName = firstName
dbUser.lastName = lastName dbUser.lastName = lastName
dbUser.emailHash = emailHash
dbUser.language = language dbUser.language = language
dbUser.publisherId = publisherId dbUser.publisherId = publisherId
dbUser.passphrase = passphrase.join(' ') dbUser.passphrase = passphrase.join(' ')
@ -442,25 +497,38 @@ export class UserResolver {
// loginUser.pubKey = keyPair[0] // loginUser.pubKey = keyPair[0]
// loginUser.privKey = encryptedPrivkey // loginUser.privKey = encryptedPrivkey
const event = new Event()
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect() await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED') await queryRunner.startTransaction('REPEATABLE READ')
try { try {
await queryRunner.manager.save(dbUser).catch((error) => { dbUser = await queryRunner.manager.save(dbUser).catch((error) => {
logger.error('Error while saving dbUser', error) logger.error('Error while saving dbUser', error)
throw new Error('error saving user') throw new Error('error saving user')
}) })
let emailContact = newEmailContact(email, dbUser.id)
emailContact = await queryRunner.manager.save(emailContact).catch((error) => {
logger.error('Error while saving emailContact', error)
throw new Error('error saving email user contact')
})
dbUser.emailContact = emailContact
dbUser.emailId = emailContact.id
await queryRunner.manager.save(dbUser).catch((error) => {
logger.error('Error while updating dbUser', error)
throw new Error('error updating user')
})
/*
const emailOptIn = newEmailOptIn(dbUser.id) const emailOptIn = newEmailOptIn(dbUser.id)
await queryRunner.manager.save(emailOptIn).catch((error) => { await queryRunner.manager.save(emailOptIn).catch((error) => {
logger.error('Error while saving emailOptIn', error) logger.error('Error while saving emailOptIn', error)
throw new Error('error saving email opt in') throw new Error('error saving email opt in')
}) })
*/
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace( const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g, /{optin}/g,
emailOptIn.verificationCode.toString(), emailContact.emailVerificationCode.toString(),
).replace(/{code}/g, redeemCode ? '/' + redeemCode : '') ).replace(/{code}/g, redeemCode ? '/' + redeemCode : '')
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -475,8 +543,6 @@ export class UserResolver {
eventSendConfirmEmail.userId = dbUser.id eventSendConfirmEmail.userId = dbUser.id
eventProtocol.writeEvent(event.setEventSendConfirmationEmail(eventSendConfirmEmail)) eventProtocol.writeEvent(event.setEventSendConfirmationEmail(eventSendConfirmEmail))
/* uncomment this, when you need the activation link on the console */
// In case EMails are disabled log the activation link for the user
if (!emailSent) { if (!emailSent) {
logger.debug(`Account confirmation link: ${activationLink}`) logger.debug(`Account confirmation link: ${activationLink}`)
} }
@ -493,10 +559,10 @@ export class UserResolver {
if (redeemCode) { if (redeemCode) {
eventRedeemRegister.userId = dbUser.id eventRedeemRegister.userId = dbUser.id
eventProtocol.writeEvent(event.setEventRedeemRegister(eventRedeemRegister)) await eventProtocol.writeEvent(event.setEventRedeemRegister(eventRedeemRegister))
} else { } else {
eventRegister.userId = dbUser.id eventRegister.userId = dbUser.id
eventProtocol.writeEvent(event.setEventRegister(eventRegister)) await eventProtocol.writeEvent(event.setEventRegister(eventRegister))
} }
return new User(dbUser) return new User(dbUser)
@ -507,22 +573,29 @@ export class UserResolver {
async forgotPassword(@Arg('email') email: string): Promise<boolean> { async forgotPassword(@Arg('email') email: string): Promise<boolean> {
logger.info(`forgotPassword(${email})...`) logger.info(`forgotPassword(${email})...`)
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
const user = await DbUser.findOne({ email }) const user = await findUserByEmail(email).catch(() => {
logger.warn(`fail on find UserContact per ${email}`)
})
if (!user) { if (!user) {
logger.warn(`no user found with ${email}`) logger.warn(`no user found with ${email}`)
return true return true
} }
// can be both types: REGISTER and RESET_PASSWORD // can be both types: REGISTER and RESET_PASSWORD
let optInCode = await LoginEmailOptIn.findOne({ // let optInCode = await LoginEmailOptIn.findOne({
userId: user.id, // userId: user.id,
}) // })
// let optInCode = user.emailContact.emailVerificationCode
const dbUserContact = await checkEmailVerificationCode(
user.emailContact,
OptInType.EMAIL_OPT_IN_RESET_PASSWORD,
)
optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD) // optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD)
logger.info(`optInCode for ${email}=${optInCode}`) logger.info(`optInCode for ${email}=${dbUserContact}`)
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendResetPasswordEmailMailer({ const emailSent = await sendResetPasswordEmailMailer({
link: activationLink(optInCode), link: activationLink(dbUserContact.emailVerificationCode),
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
email, email,
@ -532,7 +605,7 @@ export class UserResolver {
/* uncomment this, when you need the activation link on the console */ /* uncomment this, when you need the activation link on the console */
// 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.debug(`Reset password link: ${activationLink(optInCode)}`) logger.debug(`Reset password link: ${activationLink(dbUserContact.emailVerificationCode)}`)
} }
logger.info(`forgotPassword(${email}) successful...`) logger.info(`forgotPassword(${email}) successful...`)
@ -548,19 +621,29 @@ export class UserResolver {
logger.info(`setPassword(${code}, ***)...`) logger.info(`setPassword(${code}, ***)...`)
// Validate Password // Validate Password
if (!isPassword(password)) { if (!isPassword(password)) {
logger.error('Password entered is lexically invalid')
throw new Error( throw new Error(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!', 'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
) )
} }
// Load code // Load code
/*
const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: code }).catch(() => { const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: code }).catch(() => {
logger.error('Could not login with emailVerificationCode') logger.error('Could not login with emailVerificationCode')
throw new Error('Could not login with emailVerificationCode') throw new Error('Could not login with emailVerificationCode')
}) })
logger.debug('optInCode loaded...') */
const userContact = await DbUserContact.findOneOrFail(
{ emailVerificationCode: code },
{ relations: ['user'] },
).catch(() => {
logger.error('Could not login with emailVerificationCode')
throw new Error('Could not login with emailVerificationCode')
})
logger.debug('userContact loaded...')
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isOptInValid(optInCode)) { if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
logger.error( logger.error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`, `email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
) )
@ -568,14 +651,11 @@ export class UserResolver {
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`, `email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
) )
} }
logger.debug('optInCode is valid...') logger.debug('EmailVerificationCode is valid...')
// load user // load user
const user = await DbUser.findOneOrFail({ id: optInCode.userId }).catch(() => { const user = userContact.user
logger.error('Could not find corresponding Login User') logger.debug('user with EmailVerificationCode found...')
throw new Error('Could not find corresponding Login User')
})
logger.debug('user with optInCode found...')
// Generate Passphrase if needed // Generate Passphrase if needed
if (!user.passphrase) { if (!user.passphrase) {
@ -595,10 +675,10 @@ export class UserResolver {
logger.debug('Passphrase is valid...') logger.debug('Passphrase is valid...')
// Activate EMail // Activate EMail
user.emailChecked = true userContact.emailChecked = true
// Update Password // Update Password
const passwordHash = SecretKeyCryptographyCreateKey(user.email, password) // return short and long hash const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash
const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
user.password = passwordHash[0].readBigUInt64LE() // using the shorthash user.password = passwordHash[0].readBigUInt64LE() // using the shorthash
@ -608,7 +688,9 @@ export class UserResolver {
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect() await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED') await queryRunner.startTransaction('REPEATABLE READ')
const event = new Event()
try { try {
// Save user // Save user
@ -616,12 +698,21 @@ export class UserResolver {
logger.error('error saving user: ' + error) logger.error('error saving user: ' + error)
throw new Error('error saving user: ' + error) throw new Error('error saving user: ' + error)
}) })
// Save userContact
await queryRunner.manager.save(userContact).catch((error) => {
logger.error('error saving userContact: ' + error)
throw new Error('error saving userContact: ' + error)
})
await queryRunner.commitTransaction() await queryRunner.commitTransaction()
logger.info('User data written successfully...') logger.info('User and UserContact data written successfully...')
const eventActivateAccount = new EventActivateAccount()
eventActivateAccount.userId = user.id
eventProtocol.writeEvent(event.setEventActivateAccount(eventActivateAccount))
} catch (e) { } catch (e) {
await queryRunner.rollbackTransaction() await queryRunner.rollbackTransaction()
logger.error('Error on writing User data:' + e) logger.error('Error on writing User and UserContact data:' + e)
throw e throw e
} finally { } finally {
await queryRunner.release() await queryRunner.release()
@ -629,11 +720,11 @@ export class UserResolver {
// Sign into Klicktipp // Sign into Klicktipp
// TODO do we always signUp the user? How to handle things with old users? // TODO do we always signUp the user? How to handle things with old users?
if (optInCode.emailOptInTypeId === OptInType.EMAIL_OPT_IN_REGISTER) { if (userContact.emailOptInTypeId === OptInType.EMAIL_OPT_IN_REGISTER) {
try { try {
await klicktippSignIn(user.email, user.language, user.firstName, user.lastName) await klicktippSignIn(userContact.email, user.language, user.firstName, user.lastName)
logger.debug( logger.debug(
`klicktippSignIn(${user.email}, ${user.language}, ${user.firstName}, ${user.lastName})`, `klicktippSignIn(${userContact.email}, ${user.language}, ${user.firstName}, ${user.lastName})`,
) )
} catch (e) { } catch (e) {
logger.error('Error subscribe to klicktipp:' + e) logger.error('Error subscribe to klicktipp:' + e)
@ -652,10 +743,10 @@ export class UserResolver {
@Query(() => Boolean) @Query(() => Boolean)
async queryOptIn(@Arg('optIn') optIn: string): Promise<boolean> { async queryOptIn(@Arg('optIn') optIn: string): Promise<boolean> {
logger.info(`queryOptIn(${optIn})...`) logger.info(`queryOptIn(${optIn})...`)
const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: optIn }) const userContact = await DbUserContact.findOneOrFail({ emailVerificationCode: optIn })
logger.debug(`found optInCode=${optInCode}`) logger.debug(`found optInCode=${userContact}`)
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isOptInValid(optInCode)) { if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
logger.error( logger.error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`, `email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
) )
@ -703,7 +794,10 @@ export class UserResolver {
} }
// TODO: This had some error cases defined - like missing private key. This is no longer checked. // TODO: This had some error cases defined - like missing private key. This is no longer checked.
const oldPasswordHash = SecretKeyCryptographyCreateKey(userEntity.email, password) const oldPasswordHash = SecretKeyCryptographyCreateKey(
userEntity.emailContact.email,
password,
)
if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) { if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) {
logger.error(`Old password is invalid`) logger.error(`Old password is invalid`)
throw new Error(`Old password is invalid`) throw new Error(`Old password is invalid`)
@ -711,7 +805,10 @@ export class UserResolver {
const privKey = SecretKeyCryptographyDecrypt(userEntity.privKey, oldPasswordHash[1]) const privKey = SecretKeyCryptographyDecrypt(userEntity.privKey, oldPasswordHash[1])
logger.debug('oldPassword decrypted...') logger.debug('oldPassword decrypted...')
const newPasswordHash = SecretKeyCryptographyCreateKey(userEntity.email, passwordNew) // return short and long hash const newPasswordHash = SecretKeyCryptographyCreateKey(
userEntity.emailContact.email,
passwordNew,
) // return short and long hash
logger.debug('newPasswordHash created...') logger.debug('newPasswordHash created...')
const encryptedPrivkey = SecretKeyCryptographyEncrypt(privKey, newPasswordHash[1]) const encryptedPrivkey = SecretKeyCryptographyEncrypt(privKey, newPasswordHash[1])
logger.debug('PrivateKey encrypted...') logger.debug('PrivateKey encrypted...')
@ -723,10 +820,11 @@ export class UserResolver {
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect() await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED') await queryRunner.startTransaction('REPEATABLE READ')
try { try {
await queryRunner.manager.save(userEntity).catch((error) => { await queryRunner.manager.save(userEntity).catch((error) => {
logger.error('error saving user: ' + error)
throw new Error('error saving user: ' + error) throw new Error('error saving user: ' + error)
}) })
@ -747,12 +845,8 @@ export class UserResolver {
@Query(() => Boolean) @Query(() => Boolean)
async hasElopage(@Ctx() context: Context): Promise<boolean> { async hasElopage(@Ctx() context: Context): Promise<boolean> {
logger.info(`hasElopage()...`) logger.info(`hasElopage()...`)
const userEntity = context.user const userEntity = getUser(context)
if (!userEntity) { const elopageBuys = hasElopageBuys(userEntity.emailContact.email)
logger.info('missing context.user for EloPage-check')
return false
}
const elopageBuys = hasElopageBuys(userEntity.email)
logger.debug(`has ElopageBuys = ${elopageBuys}`) logger.debug(`has ElopageBuys = ${elopageBuys}`)
return elopageBuys return elopageBuys
} }
@ -788,19 +882,58 @@ export class UserResolver {
} }
} }
export async function findUserByEmail(email: string): Promise<DbUser> {
const dbUserContact = await DbUserContact.findOneOrFail(
{ email: email },
{ withDeleted: true, relations: ['user'] },
).catch(() => {
logger.error(`UserContact with email=${email} does not exists`)
throw new Error('No user with this credentials')
})
const dbUser = dbUserContact.user
dbUser.emailContact = dbUserContact
return dbUser
}
async function checkEmailExists(email: string): Promise<boolean> {
const userContact = await DbUserContact.findOne({ email: email }, { withDeleted: true })
if (userContact) {
return true
}
return false
}
/*
const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => { const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => {
const timeElapsed = Date.now() - new Date(optIn.updatedAt).getTime() const timeElapsed = Date.now() - new Date(optIn.updatedAt).getTime()
// time is given in minutes // time is given in minutes
return timeElapsed <= duration * 60 * 1000 return timeElapsed <= duration * 60 * 1000
} }
*/
const isTimeExpired = (updatedAt: Date, duration: number): boolean => {
const timeElapsed = Date.now() - new Date(updatedAt).getTime()
// time is given in minutes
return timeElapsed <= duration * 60 * 1000
}
/*
const isOptInValid = (optIn: LoginEmailOptIn): boolean => { const isOptInValid = (optIn: LoginEmailOptIn): boolean => {
return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME) return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME)
} }
*/
const isEmailVerificationCodeValid = (updatedAt: Date | null): boolean => {
if (updatedAt == null) {
return true
}
return isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_VALID_TIME)
}
/*
const canResendOptIn = (optIn: LoginEmailOptIn): boolean => { const canResendOptIn = (optIn: LoginEmailOptIn): boolean => {
return !isTimeExpired(optIn, CONFIG.EMAIL_CODE_REQUEST_TIME) return !isTimeExpired(optIn, CONFIG.EMAIL_CODE_REQUEST_TIME)
} }
*/
const canEmailResend = (updatedAt: Date): boolean => {
return !isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_REQUEST_TIME)
}
const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => { const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => {
if (time > 60) { if (time > 60) {

View File

@ -15,14 +15,21 @@ export const validateContribution = (
amount: Decimal, amount: Decimal,
creationDate: Date, creationDate: Date,
): void => { ): void => {
logger.trace('isContributionValid', creations, amount, creationDate) logger.trace('isContributionValid: ', creations, amount, creationDate)
const index = getCreationIndex(creationDate.getMonth()) const index = getCreationIndex(creationDate.getMonth())
if (index < 0) { if (index < 0) {
logger.error(
'No information for available creations with the given creationDate=',
creationDate,
)
throw new Error('No information for available creations for the given date') throw new Error('No information for available creations for the given date')
} }
if (amount.greaterThan(creations[index].toString())) { if (amount.greaterThan(creations[index].toString())) {
logger.error(
`The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`,
)
throw new Error( throw new Error(
`The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`, `The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`,
) )
@ -41,7 +48,7 @@ export const getUserCreations = async (
await queryRunner.connect() await queryRunner.connect()
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 unionString = includePending
? ` ? `
@ -51,6 +58,7 @@ export const getUserCreations = async (
AND contribution_date >= ${dateFilter} AND contribution_date >= ${dateFilter}
AND confirmed_at IS NULL AND deleted_at IS NULL` AND confirmed_at IS NULL AND deleted_at IS NULL`
: '' : ''
logger.trace('getUserCreations unionString=', unionString)
const unionQuery = await queryRunner.manager.query(` const unionQuery = await queryRunner.manager.query(`
SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM
@ -62,6 +70,7 @@ export const getUserCreations = async (
GROUP BY month, userId GROUP BY month, userId
ORDER BY date DESC ORDER BY date DESC
`) `)
logger.trace('getUserCreations unionQuery=', unionQuery)
await queryRunner.release() await queryRunner.release()
@ -82,6 +91,7 @@ export const getUserCreations = async (
export const getUserCreation = async (id: number, includePending = true): Promise<Decimal[]> => { export const getUserCreation = async (id: number, includePending = true): Promise<Decimal[]> => {
logger.trace('getUserCreation', id, includePending) logger.trace('getUserCreation', id, includePending)
const creations = await getUserCreations([id], includePending) const creations = await getUserCreations([id], includePending)
logger.trace('getUserCreation creations=', creations)
return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE
} }

View File

@ -0,0 +1,40 @@
import { sendAddedContributionMessageEmail } from './sendAddedContributionMessageEmail'
import { sendEMail } from './sendEMail'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendAddedContributionMessageEmail', () => {
beforeEach(async () => {
await sendAddedContributionMessageEmail({
senderFirstName: 'Peter',
senderLastName: 'Lustig',
recipientFirstName: 'Bibi',
recipientLastName: 'Bloxberg',
recipientEmail: 'bibi@bloxberg.de',
senderEmail: 'peter@lustig.de',
contributionMemo: 'Vielen herzlichen Dank für den neuen Hexenbesen!',
message: 'Was für ein Besen ist es geworden?',
overviewURL: 'http://localhost/overview',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: `Bibi Bloxberg <bibi@bloxberg.de>`,
subject: 'Gradido Frage zur Schöpfung',
text:
expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining('Peter Lustig') &&
expect.stringContaining(
'Du hast soeben zu deinem eingereichten Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" eine Rückfrage von Peter Lustig erhalten.',
) &&
expect.stringContaining('Was für ein Besen ist es geworden?') &&
expect.stringContaining('http://localhost/overview'),
})
})
})

View File

@ -0,0 +1,26 @@
import { backendLogger as logger } from '@/server/logger'
import { sendEMail } from './sendEMail'
import { contributionMessageReceived } from './text/contributionMessageReceived'
export const sendAddedContributionMessageEmail = (data: {
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
recipientEmail: string
senderEmail: string
contributionMemo: string
message: string
overviewURL: string
}): Promise<boolean> => {
logger.info(
`sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>,
subject=${contributionMessageReceived.de.subject},
text=${contributionMessageReceived.de.text(data)}`,
)
return sendEMail({
to: `${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>`,
subject: contributionMessageReceived.de.subject,
text: contributionMessageReceived.de.text(data),
})
}

View File

@ -0,0 +1,39 @@
import Decimal from 'decimal.js-light'
import { sendContributionConfirmedEmail } from './sendContributionConfirmedEmail'
import { sendEMail } from './sendEMail'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendContributionConfirmedEmail', () => {
beforeEach(async () => {
await sendContributionConfirmedEmail({
senderFirstName: 'Peter',
senderLastName: 'Lustig',
recipientFirstName: 'Bibi',
recipientLastName: 'Bloxberg',
recipientEmail: 'bibi@bloxberg.de',
contributionMemo: 'Vielen herzlichen Dank für den neuen Hexenbesen!',
contributionAmount: new Decimal(200.0),
overviewURL: 'http://localhost/overview',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: 'Bibi Bloxberg <bibi@bloxberg.de>',
subject: 'Schöpfung wurde bestätigt',
text:
expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining(
'Dein Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben bestätigt.',
) &&
expect.stringContaining('Betrag: 200,00 GDD') &&
expect.stringContaining('Link zu deinem Konto: http://localhost/overview'),
})
})
})

View File

@ -0,0 +1,26 @@
import { backendLogger as logger } from '@/server/logger'
import Decimal from 'decimal.js-light'
import { sendEMail } from './sendEMail'
import { contributionConfirmed } from './text/contributionConfirmed'
export const sendContributionConfirmedEmail = (data: {
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
recipientEmail: string
contributionMemo: string
contributionAmount: Decimal
overviewURL: string
}): Promise<boolean> => {
logger.info(
`sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>,
subject=${contributionConfirmed.de.subject},
text=${contributionConfirmed.de.text(data)}`,
)
return sendEMail({
to: `${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>`,
subject: contributionConfirmed.de.subject,
text: contributionConfirmed.de.text(data),
})
}

View File

@ -73,7 +73,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: 'receiver@mail.org', to: `${CONFIG.EMAIL_TEST_RECEIVER}`,
cc: 'support@gradido.net', cc: 'support@gradido.net',
subject: 'Subject', subject: 'Subject',
text: 'Text text text', text: 'Text text text',

View File

@ -19,6 +19,12 @@ export const sendEMail = async (emailDef: {
logger.info(`Emails are disabled via config...`) logger.info(`Emails are disabled via config...`)
return false return false
} }
if (CONFIG.EMAIL_TEST_MODUS) {
logger.info(
`Testmodus=ON: change receiver from ${emailDef.to} to ${CONFIG.EMAIL_TEST_RECEIVER}`,
)
emailDef.to = CONFIG.EMAIL_TEST_RECEIVER
}
const transporter = createTransport({ const transporter = createTransport({
host: CONFIG.EMAIL_SMTP_URL, host: CONFIG.EMAIL_SMTP_URL,
port: Number(CONFIG.EMAIL_SMTP_PORT), port: Number(CONFIG.EMAIL_SMTP_PORT),

View File

@ -0,0 +1,44 @@
import { sendEMail } from './sendEMail'
import Decimal from 'decimal.js-light'
import { sendTransactionLinkRedeemedEmail } from './sendTransactionLinkRedeemed'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendTransactionLinkRedeemedEmail', () => {
beforeEach(async () => {
await sendTransactionLinkRedeemedEmail({
email: 'bibi@bloxberg.de',
senderFirstName: 'Peter',
senderLastName: 'Lustig',
recipientFirstName: 'Bibi',
recipientLastName: 'Bloxberg',
senderEmail: 'peter@lustig.de',
amount: new Decimal(42.0),
memo: 'Vielen Dank dass Du dabei bist',
overviewURL: 'http://localhost/overview',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: `Bibi Bloxberg <bibi@bloxberg.de>`,
subject: 'Gradido-Link wurde eingelöst',
text:
expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining(
'Peter Lustig (peter@lustig.de) hat soeben deinen Link eingelöst.',
) &&
expect.stringContaining('Betrag: 42,00 GDD,') &&
expect.stringContaining('Memo: Vielen Dank dass Du dabei bist') &&
expect.stringContaining(
'Details zur Transaktion findest du in deinem Gradido-Konto: http://localhost/overview',
) &&
expect.stringContaining('Bitte antworte nicht auf diese E-Mail!'),
})
})
})

View File

@ -0,0 +1,28 @@
import { backendLogger as logger } from '@/server/logger'
import Decimal from 'decimal.js-light'
import { sendEMail } from './sendEMail'
import { transactionLinkRedeemed } from './text/transactionLinkRedeemed'
export const sendTransactionLinkRedeemedEmail = (data: {
email: string
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
senderEmail: string
amount: Decimal
memo: string
overviewURL: string
}): Promise<boolean> => {
logger.info(
`sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName},
<${data.email}>,
subject=${transactionLinkRedeemed.de.subject},
text=${transactionLinkRedeemed.de.text(data)}`,
)
return sendEMail({
to: `${data.recipientFirstName} ${data.recipientLastName} <${data.email}>`,
subject: transactionLinkRedeemed.de.subject,
text: transactionLinkRedeemed.de.text(data),
})
}

View File

@ -19,7 +19,6 @@ describe('sendTransactionReceivedEmail', () => {
email: 'peter@lustig.de', email: 'peter@lustig.de',
senderEmail: 'bibi@bloxberg.de', senderEmail: 'bibi@bloxberg.de',
amount: new Decimal(42.0), amount: new Decimal(42.0),
memo: 'Vielen herzlichen Dank für den neuen Hexenbesen!',
overviewURL: 'http://localhost/overview', overviewURL: 'http://localhost/overview',
}) })
}) })
@ -33,7 +32,6 @@ describe('sendTransactionReceivedEmail', () => {
expect.stringContaining('42,00 GDD') && expect.stringContaining('42,00 GDD') &&
expect.stringContaining('Bibi Bloxberg') && expect.stringContaining('Bibi Bloxberg') &&
expect.stringContaining('(bibi@bloxberg.de)') && expect.stringContaining('(bibi@bloxberg.de)') &&
expect.stringContaining('Vielen herzlichen Dank für den neuen Hexenbesen!') &&
expect.stringContaining('http://localhost/overview'), expect.stringContaining('http://localhost/overview'),
}) })
}) })

View File

@ -11,7 +11,6 @@ export const sendTransactionReceivedEmail = (data: {
email: string email: string
senderEmail: string senderEmail: string
amount: Decimal amount: Decimal
memo: string
overviewURL: string overviewURL: string
}): Promise<boolean> => { }): Promise<boolean> => {
logger.info( logger.info(

View File

@ -0,0 +1,31 @@
import Decimal from 'decimal.js-light'
export const contributionConfirmed = {
de: {
subject: 'Schöpfung wurde bestätigt',
text: (data: {
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
contributionMemo: string
contributionAmount: Decimal
overviewURL: string
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName},
Dein eingereichter Gemeinwohl-Beitrag "${data.contributionMemo}" wurde soeben von ${
data.senderFirstName
} ${data.senderLastName} bestätigt.
Betrag: ${data.contributionAmount.toFixed(2).replace('.', ',')} GDD
Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen,
dein Gradido-Team
Link zu deinem Konto: ${data.overviewURL}`,
},
}

View File

@ -0,0 +1,28 @@
export const contributionMessageReceived = {
de: {
subject: 'Gradido Frage zur Schöpfung',
text: (data: {
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
recipientEmail: string
senderEmail: string
contributionMemo: string
message: string
overviewURL: string
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName},
du hast soeben zu deinem eingereichten Gemeinwohl-Beitrag "${data.contributionMemo}" eine Rückfrage von ${data.senderFirstName} ${data.senderLastName} erhalten.
Bitte beantworte die Rückfrage in deinem Gradido-Konto im Menü "Gemeinschaft" im Tab "Meine Beiträge zum Gemeinwohl"!
Link zu deinem Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen,
dein Gradido-Team`,
},
}

View File

@ -0,0 +1,33 @@
import Decimal from 'decimal.js-light'
export const transactionLinkRedeemed = {
de: {
subject: 'Gradido-Link wurde eingelöst',
text: (data: {
email: string
senderFirstName: string
senderLastName: string
recipientFirstName: string
recipientLastName: string
senderEmail: string
amount: Decimal
memo: string
overviewURL: string
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName}
${data.senderFirstName} ${data.senderLastName} (${
data.senderEmail
}) hat soeben deinen Link eingelöst.
Betrag: ${data.amount.toFixed(2).replace('.', ',')} GDD,
Memo: ${data.memo}
Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen,
dein Gradido-Team`,
},
}

View File

@ -11,7 +11,6 @@ export const transactionReceived = {
email: string email: string
senderEmail: string senderEmail: string
amount: Decimal amount: Decimal
memo: string
overviewURL: string overviewURL: string
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName} `Hallo ${data.recipientFirstName} ${data.recipientLastName}
@ -19,16 +18,12 @@ export const transactionReceived = {
Du hast soeben ${data.amount.toFixed(2).replace('.', ',')} GDD von ${data.senderFirstName} ${ Du hast soeben ${data.amount.toFixed(2).replace('.', ',')} GDD von ${data.senderFirstName} ${
data.senderLastName data.senderLastName
} (${data.senderEmail}) erhalten. } (${data.senderEmail}) erhalten.
${data.senderFirstName} ${data.senderLastName} schreibt:
${data.memo} Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Mit freundlichen Grüßen,
dein Gradido-Team dein Gradido-Team`,
Link zu deinem Konto: ${data.overviewURL}`,
}, },
} }

View File

@ -11,7 +11,11 @@ export const contributionLinkFactory = async (
const { mutate, query } = client const { mutate, query } = client
// login as admin // login as admin
await query({ query: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } }) // eslint-disable-next-line @typescript-eslint/no-unused-vars
const user = await query({
query: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
const variables = { const variables = {
amount: contributionLink.amount, amount: contributionLink.amount,

View File

@ -1,13 +1,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { backendLogger as logger } from '@/server/logger'
import { adminCreateContribution, confirmContribution } from '@/seeds/graphql/mutations' import { adminCreateContribution, confirmContribution } from '@/seeds/graphql/mutations'
import { login } from '@/seeds/graphql/queries' import { login } from '@/seeds/graphql/queries'
import { CreationInterface } from '@/seeds/creation/CreationInterface' import { CreationInterface } from '@/seeds/creation/CreationInterface'
import { ApolloServerTestClient } from 'apollo-server-testing' import { ApolloServerTestClient } from 'apollo-server-testing'
import { User } from '@entity/User'
import { Transaction } from '@entity/Transaction' import { Transaction } from '@entity/Transaction'
import { Contribution } from '@entity/Contribution' import { Contribution } from '@entity/Contribution'
import { findUserByEmail } from '@/graphql/resolver/UserResolver'
// import CONFIG from '@/config/index' // import CONFIG from '@/config/index'
export const nMonthsBefore = (date: Date, months = 1): string => { export const nMonthsBefore = (date: Date, months = 1): string => {
@ -19,29 +20,41 @@ export const creationFactory = async (
creation: CreationInterface, creation: CreationInterface,
): Promise<Contribution | void> => { ): Promise<Contribution | void> => {
const { mutate, query } = client const { mutate, query } = client
logger.trace('creationFactory...')
await query({ query: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } }) await query({ query: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } })
logger.trace('creationFactory... after login')
// TODO it would be nice to have this mutation return the id // TODO it would be nice to have this mutation return the id
await mutate({ mutation: adminCreateContribution, variables: { ...creation } }) await mutate({ mutation: adminCreateContribution, variables: { ...creation } })
logger.trace('creationFactory... after adminCreateContribution')
const user = await User.findOneOrFail({ where: { email: creation.email } }) const user = await findUserByEmail(creation.email) // userContact.user
const pendingCreation = await Contribution.findOneOrFail({ const pendingCreation = await Contribution.findOneOrFail({
where: { userId: user.id, amount: creation.amount }, where: { userId: user.id, amount: creation.amount },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
}) })
logger.trace(
'creationFactory... after Contribution.findOneOrFail pendingCreation=',
pendingCreation,
)
if (creation.confirmed) { if (creation.confirmed) {
logger.trace('creationFactory... creation.confirmed=', creation.confirmed)
await mutate({ mutation: confirmContribution, variables: { id: pendingCreation.id } }) await mutate({ mutation: confirmContribution, variables: { id: pendingCreation.id } })
logger.trace('creationFactory... after confirmContribution')
const confirmedCreation = await Contribution.findOneOrFail({ id: pendingCreation.id }) const confirmedCreation = await Contribution.findOneOrFail({ id: pendingCreation.id })
logger.trace(
'creationFactory... after Contribution.findOneOrFail confirmedCreation=',
confirmedCreation,
)
if (creation.moveCreationDate) { if (creation.moveCreationDate) {
logger.trace('creationFactory... creation.moveCreationDate=', creation.moveCreationDate)
const transaction = await Transaction.findOneOrFail({ const transaction = await Transaction.findOneOrFail({
where: { userId: user.id, creationDate: new Date(creation.creationDate) }, where: { userId: user.id, creationDate: new Date(creation.creationDate) },
order: { balanceDate: 'DESC' }, order: { balanceDate: 'DESC' },
}) })
logger.trace('creationFactory... after Transaction.findOneOrFail transaction=', transaction)
if (transaction.decay.equals(0) && transaction.creationDate) { if (transaction.decay.equals(0) && transaction.creationDate) {
confirmedCreation.contributionDate = new Date( confirmedCreation.contributionDate = new Date(
nMonthsBefore(transaction.creationDate, creation.moveCreationDate), nMonthsBefore(transaction.creationDate, creation.moveCreationDate),
@ -52,11 +65,17 @@ export const creationFactory = async (
transaction.balanceDate = new Date( transaction.balanceDate = new Date(
nMonthsBefore(transaction.balanceDate, creation.moveCreationDate), nMonthsBefore(transaction.balanceDate, creation.moveCreationDate),
) )
logger.trace('creationFactory... before transaction.save transaction=', transaction)
await transaction.save() await transaction.save()
logger.trace(
'creationFactory... before confirmedCreation.save confirmedCreation=',
confirmedCreation,
)
await confirmedCreation.save() await confirmedCreation.save()
} }
} }
} else { } else {
logger.trace('creationFactory... pendingCreation=', pendingCreation)
return pendingCreation return pendingCreation
} }
} }

View File

@ -1,6 +1,5 @@
import { createUser, setPassword } from '@/seeds/graphql/mutations' import { createUser, setPassword } from '@/seeds/graphql/mutations'
import { User } from '@entity/User' import { User } from '@entity/User'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { UserInterface } from '@/seeds/users/UserInterface' import { UserInterface } from '@/seeds/users/UserInterface'
import { ApolloServerTestClient } from 'apollo-server-testing' import { ApolloServerTestClient } from 'apollo-server-testing'
@ -15,17 +14,23 @@ export const userFactory = async (
createUser: { id }, createUser: { id },
}, },
} = await mutate({ mutation: createUser, variables: user }) } = await mutate({ mutation: createUser, variables: user })
// console.log('creatUser:', { id }, { user })
// get user from database
let dbUser = await User.findOneOrFail({ id }, { relations: ['emailContact'] })
// console.log('dbUser:', dbUser)
const emailContact = dbUser.emailContact
// console.log('emailContact:', emailContact)
if (user.emailChecked) { if (user.emailChecked) {
const optin = await LoginEmailOptIn.findOneOrFail({ userId: id })
await mutate({ await mutate({
mutation: setPassword, mutation: setPassword,
variables: { password: 'Aa12345_', code: optin.verificationCode }, variables: { password: 'Aa12345_', code: emailContact.emailVerificationCode },
}) })
} }
// get user from database // get last changes of user from database
const dbUser = await User.findOneOrFail({ id }) dbUser = await User.findOneOrFail({ id })
if (user.createdAt || user.deletedAt || user.isAdmin) { if (user.createdAt || user.deletedAt || user.isAdmin) {
if (user.createdAt) dbUser.createdAt = user.createdAt if (user.createdAt) dbUser.createdAt = user.createdAt
@ -34,5 +39,8 @@ export const userFactory = async (
await dbUser.save() await dbUser.save()
} }
// get last changes of user from database
// dbUser = await User.findOneOrFail({ id }, { withDeleted: true })
return dbUser return dbUser
} }

View File

@ -261,3 +261,31 @@ export const deleteContribution = gql`
deleteContribution(id: $id) deleteContribution(id: $id)
} }
` `
export const createContributionMessage = gql`
mutation ($contributionId: Float!, $message: String!) {
createContributionMessage(contributionId: $contributionId, message: $message) {
id
message
createdAt
updatedAt
type
userFirstName
userLastName
}
}
`
export const adminCreateContributionMessage = gql`
mutation ($contributionId: Float!, $message: String!) {
adminCreateContributionMessage(contributionId: $contributionId, message: $message) {
id
message
createdAt
updatedAt
type
userFirstName
userLastName
}
}
`

View File

@ -292,3 +292,26 @@ export const searchAdminUsers = gql`
} }
} }
` `
export const listContributionMessages = gql`
query ($contributionId: Float!, $pageSize: Int = 25, $currentPage: Int = 1, $order: Order = ASC) {
listContributionMessages(
contributionId: $contributionId
pageSize: $pageSize
currentPage: $currentPage
order: $order
) {
count
messages {
id
message
createdAt
updatedAt
type
userFirstName
userLastName
userId
}
}
}
`

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { backendLogger as logger } from '@/server/logger'
import createServer from '../server/createServer' import createServer from '../server/createServer'
import { createTestClient } from 'apollo-server-testing' import { createTestClient } from 'apollo-server-testing'
@ -50,11 +51,14 @@ const run = async () => {
const seedClient = createTestClient(server.apollo) const seedClient = createTestClient(server.apollo)
const { con } = server const { con } = server
await cleanDB() await cleanDB()
logger.info('##seed## clean database successful...')
// seed the standard users // seed the standard users
for (let i = 0; i < users.length; i++) { for (let i = 0; i < users.length; i++) {
await userFactory(seedClient, users[i]) const dbUser = await userFactory(seedClient, users[i])
logger.info(`##seed## seed standard users[ ${i} ]= ${JSON.stringify(dbUser, null, 2)}`)
} }
logger.info('##seed## seeding all standard users successful...')
// seed 100 random users // seed 100 random users
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
@ -64,7 +68,9 @@ const run = async () => {
email: internet.email(), email: internet.email(),
language: datatype.boolean() ? 'en' : 'de', language: datatype.boolean() ? 'en' : 'de',
}) })
logger.info(`##seed## seed ${i}. random user`)
} }
logger.info('##seed## seeding all random users successful...')
// create GDD // create GDD
for (let i = 0; i < creations.length; i++) { for (let i = 0; i < creations.length; i++) {
@ -73,16 +79,19 @@ const run = async () => {
// eslint-disable-next-line no-empty // eslint-disable-next-line no-empty
while (new Date().getTime() < now + 1000) {} // we have to wait a little! quick fix for account sum problem of bob@baumeister.de, (see https://github.com/gradido/gradido/issues/1886) while (new Date().getTime() < now + 1000) {} // we have to wait a little! quick fix for account sum problem of bob@baumeister.de, (see https://github.com/gradido/gradido/issues/1886)
} }
logger.info('##seed## seeding all creations successful...')
// create Transaction Links // create Transaction Links
for (let i = 0; i < transactionLinks.length; i++) { for (let i = 0; i < transactionLinks.length; i++) {
await transactionLinkFactory(seedClient, transactionLinks[i]) await transactionLinkFactory(seedClient, transactionLinks[i])
} }
logger.info('##seed## seeding all transactionLinks successful...')
// create Contribution Links // create Contribution Links
for (let i = 0; i < contributionLinks.length; i++) { for (let i = 0; i < contributionLinks.length; i++) {
await contributionLinkFactory(seedClient, contributionLinks[i]) await contributionLinkFactory(seedClient, contributionLinks[i])
} }
logger.info('##seed## seeding all contributionLinks successful...')
await con.close() await con.close()
} }

View File

@ -9,6 +9,7 @@ export interface Context {
setHeaders: { key: string; value: string }[] setHeaders: { key: string; value: string }[]
role?: Role role?: Role
user?: dbUser user?: dbUser
clientRequestTime?: string
// hack to use less DB calls for Balance Resolver // hack to use less DB calls for Balance Resolver
lastTransaction?: dbTransaction lastTransaction?: dbTransaction
transactionCount?: number transactionCount?: number
@ -18,14 +19,17 @@ export interface Context {
const context = (args: ExpressContext): Context => { const context = (args: ExpressContext): Context => {
const authorization = args.req.headers.authorization const authorization = args.req.headers.authorization
let token: string | null = null const clientRequestTime = args.req.headers.clientrequesttime
if (authorization) { const context: Context = {
token = authorization.replace(/^Bearer /, '') token: null,
}
const context = {
token,
setHeaders: [], setHeaders: [],
} }
if (authorization) {
context.token = authorization.replace(/^Bearer /, '')
}
if (clientRequestTime && typeof clientRequestTime === 'string') {
context.clientRequestTime = clientRequestTime
}
return context return context
} }

View File

@ -75,6 +75,9 @@ const createServer = async (
logger, logger,
}) })
apollo.applyMiddleware({ app, path: '/' }) apollo.applyMiddleware({ app, path: '/' })
logger.info(
`running with PRODUCTION=${CONFIG.PRODUCTION}, sending EMAIL enabled=${CONFIG.EMAIL} and EMAIL_TEST_MODUS=${CONFIG.EMAIL_TEST_MODUS} ...`,
)
logger.debug('createServer...successful') logger.debug('createServer...successful')
return { apollo, app, con } return { apollo, app, con }
} }

View File

@ -1,28 +1,39 @@
import { Brackets, EntityRepository, ObjectLiteral, Repository } from '@dbTools/typeorm' import SearchUsersFilters from '@/graphql/arg/SearchUsersFilters'
import { User } from '@entity/User' import { Brackets, EntityRepository, IsNull, Not, Repository } from '@dbTools/typeorm'
import { User as DbUser } from '@entity/User'
@EntityRepository(User) @EntityRepository(DbUser)
export class UserRepository extends Repository<User> { export class UserRepository extends Repository<DbUser> {
async findByPubkeyHex(pubkeyHex: string): Promise<User> { async findByPubkeyHex(pubkeyHex: string): Promise<DbUser> {
return this.createQueryBuilder('user') const dbUser = await this.createQueryBuilder('user')
.leftJoinAndSelect('user.emailContact', 'emailContact')
.where('hex(user.pubKey) = :pubkeyHex', { pubkeyHex }) .where('hex(user.pubKey) = :pubkeyHex', { pubkeyHex })
.getOneOrFail() .getOneOrFail()
/*
const dbUser = await this.findOneOrFail(`hex(user.pubKey) = { pubkeyHex }`)
const emailContact = await this.query(
`SELECT * from user_contacts where id = { dbUser.emailId }`,
)
dbUser.emailContact = emailContact
*/
return dbUser
} }
async findBySearchCriteriaPagedFiltered( async findBySearchCriteriaPagedFiltered(
select: string[], select: string[],
searchCriteria: string, searchCriteria: string,
filterCriteria: ObjectLiteral[], filters: SearchUsersFilters,
currentPage: number, currentPage: number,
pageSize: number, pageSize: number,
): Promise<[User[], number]> { ): Promise<[DbUser[], number]> {
const query = await this.createQueryBuilder('user') const query = this.createQueryBuilder('user')
.select(select) .select(select)
.leftJoinAndSelect('user.emailContact', 'emailContact')
.withDeleted() .withDeleted()
.where( .where(
new Brackets((qb) => { new Brackets((qb) => {
qb.where( qb.where(
'user.firstName like :name or user.lastName like :lastName or user.email like :email', 'user.firstName like :name or user.lastName like :lastName or emailContact.email like :email',
{ {
name: `%${searchCriteria}%`, name: `%${searchCriteria}%`,
lastName: `%${searchCriteria}%`, lastName: `%${searchCriteria}%`,
@ -31,9 +42,23 @@ export class UserRepository extends Repository<User> {
) )
}), }),
) )
/*
filterCriteria.forEach((filter) => { filterCriteria.forEach((filter) => {
query.andWhere(filter) query.andWhere(filter)
}) })
*/
if (filters) {
if (filters.byActivated !== null) {
query.andWhere('emailContact.emailChecked = :value', { value: filters.byActivated })
// filterCriteria.push({ 'emailContact.emailChecked': filters.byActivated })
}
if (filters.byDeleted !== null) {
// filterCriteria.push({ deletedAt: filters.byDeleted ? Not(IsNull()) : IsNull() })
query.andWhere({ deletedAt: filters.byDeleted ? Not(IsNull()) : IsNull() })
}
}
return query return query
.take(pageSize) .take(pageSize)
.skip((currentPage - 1) * pageSize) .skip((currentPage - 1) * pageSize)

View File

@ -2,22 +2,26 @@
import { SaveOptions, RemoveOptions } from '@dbTools/typeorm' import { SaveOptions, RemoveOptions } from '@dbTools/typeorm'
import { User as dbUser } from '@entity/User' import { User as dbUser } from '@entity/User'
import { UserContact } from '@entity/UserContact'
// import { UserContact as EmailContact } from '@entity/UserContact'
import { User } from '@model/User' import { User } from '@model/User'
const communityDbUser: dbUser = { const communityDbUser: dbUser = {
id: -1, id: -1,
gradidoID: '11111111-2222-4333-4444-55555555', gradidoID: '11111111-2222-4333-4444-55555555',
alias: '', alias: '',
email: 'support@gradido.net', // email: 'support@gradido.net',
emailContact: new UserContact(),
emailId: -1,
firstName: 'Gradido', firstName: 'Gradido',
lastName: 'Akademie', lastName: 'Akademie',
pubKey: Buffer.from(''), pubKey: Buffer.from(''),
privKey: Buffer.from(''), privKey: Buffer.from(''),
deletedAt: null, deletedAt: null,
password: BigInt(0), password: BigInt(0),
emailHash: Buffer.from(''), // emailHash: Buffer.from(''),
createdAt: new Date(), createdAt: new Date(),
emailChecked: false, // emailChecked: false,
language: '', language: '',
isAdmin: null, isAdmin: null,
publisherId: 0, publisherId: 0,

View File

@ -0,0 +1,28 @@
import connection from '@/typeorm/connection'
import { getKlickTippUser } from '@/apis/KlicktippController'
import { User } from '@entity/User'
export async function retrieveNotRegisteredEmails(): Promise<string[]> {
const con = await connection()
if (!con) {
throw new Error('No connection to database')
}
const users = await User.find({ relations: ['emailContact'] })
const notRegisteredUser = []
for (let i = 0; i < users.length; i++) {
const user = users[i]
try {
await getKlickTippUser(user.emailContact.email)
} catch (err) {
notRegisteredUser.push(user.emailContact.email)
// eslint-disable-next-line no-console
console.log(`${user.emailContact.email}`)
}
}
await con.close()
// eslint-disable-next-line no-console
console.log('User die nicht bei KlickTipp vorhanden sind: ', notRegisteredUser)
return notRegisteredUser
}
retrieveNotRegisteredEmails()

View File

@ -29,7 +29,7 @@
import { LoginElopageBuys } from '@entity/LoginElopageBuys' import { LoginElopageBuys } from '@entity/LoginElopageBuys'
import { UserResolver } from '@/graphql/resolver/UserResolver' import { UserResolver } from '@/graphql/resolver/UserResolver'
import { User as dbUser } from '@entity/User' import { UserContact as dbUserContact } from '@entity/UserContact'
export const elopageWebhook = async (req: any, res: any): Promise<void> => { export const elopageWebhook = async (req: any, res: any): Promise<void> => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -127,7 +127,8 @@ export const elopageWebhook = async (req: any, res: any): Promise<void> => {
} }
// Do we already have such a user? // Do we already have such a user?
if ((await dbUser.count({ email })) !== 0) { // if ((await dbUser.count({ email })) !== 0) {
if ((await dbUserContact.count({ email })) !== 0) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`Did not create User - already exists with email: ${email}`) console.log(`Did not create User - already exists with email: ${email}`)
return return

View File

@ -8,6 +8,7 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
} from 'typeorm' } from 'typeorm'
import { Contribution } from '../Contribution' import { Contribution } from '../Contribution'
import { User } from '../User'
@Entity('contribution_messages', { @Entity('contribution_messages', {
engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci', engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
@ -26,6 +27,10 @@ export class ContributionMessage extends BaseEntity {
@Column({ name: 'user_id', unsigned: true, nullable: false }) @Column({ name: 'user_id', unsigned: true, nullable: false })
userId: number userId: number
@ManyToOne(() => User, (user) => user.messages)
@JoinColumn({ name: 'user_id' })
user: User
@Column({ length: 2000, nullable: false, collation: 'utf8mb4_unicode_ci' }) @Column({ length: 2000, nullable: false, collation: 'utf8mb4_unicode_ci' })
message: string message: string

View File

@ -0,0 +1,116 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToMany,
JoinColumn,
} from 'typeorm'
import { Contribution } from '../Contribution'
import { ContributionMessage } from '../ContributionMessage'
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class User extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({
name: 'gradido_id',
length: 36,
nullable: false,
unique: true,
collation: 'utf8mb4_unicode_ci',
})
gradidoID: string
@Column({
name: 'alias',
length: 20,
nullable: true,
unique: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
alias: string
@Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true })
pubKey: Buffer
@Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true })
privKey: Buffer
@Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' })
email: string
@Column({
name: 'first_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
firstName: string
@Column({
name: 'last_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
lastName: string
@DeleteDateColumn()
deletedAt: Date | null
@Column({ type: 'bigint', default: 0, unsigned: true })
password: BigInt
@Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true })
emailHash: Buffer
@Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP', nullable: false })
createdAt: Date
@Column({ name: 'email_checked', type: 'bool', nullable: false, default: false })
emailChecked: boolean
@Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false })
language: string
@Column({ name: 'is_admin', type: 'datetime', nullable: true, default: null })
isAdmin: Date | null
@Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null })
referrerId?: number | null
@Column({
name: 'contribution_link_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
contributionLinkId?: number | null
@Column({ name: 'publisher_id', default: 0 })
publisherId: number
@Column({
type: 'text',
name: 'passphrase',
collation: 'utf8mb4_unicode_ci',
nullable: true,
default: null,
})
passphrase: string
@OneToMany(() => Contribution, (contribution) => contribution.user)
@JoinColumn({ name: 'user_id' })
contributions?: Contribution[]
@OneToMany(() => ContributionMessage, (message) => message.user)
@JoinColumn({ name: 'user_id' })
messages?: ContributionMessage[]
}

View File

@ -0,0 +1,54 @@
import {
BaseEntity,
Column,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm'
import { Contribution } from '../Contribution'
import { User } from '../User'
@Entity('contribution_messages', {
engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
})
export class ContributionMessage extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'contribution_id', unsigned: true, nullable: false })
contributionId: number
@ManyToOne(() => Contribution, (contribution) => contribution.messages)
@JoinColumn({ name: 'contribution_id' })
contribution: Contribution
@Column({ name: 'user_id', unsigned: true, nullable: false })
userId: number
@ManyToOne(() => User, (user) => user.messages)
@JoinColumn({ name: 'user_id' })
user: User
@Column({ length: 2000, nullable: false, collation: 'utf8mb4_unicode_ci' })
message: string
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt: Date
@Column({ type: 'datetime', default: null, nullable: true, name: 'updated_at' })
updatedAt: Date
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt: Date | null
@Column({ name: 'deleted_by', default: null, unsigned: true, nullable: true })
deletedBy: number
@Column({ length: 12, nullable: false, collation: 'utf8mb4_unicode_ci' })
type: string
@Column({ name: 'is_moderator', type: 'bool', nullable: false, default: false })
isModerator: boolean
}

View File

@ -0,0 +1,126 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToMany,
JoinColumn,
OneToOne,
} from 'typeorm'
import { Contribution } from '../Contribution'
import { ContributionMessage } from '../ContributionMessage'
import { UserContact } from '../UserContact'
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class User extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({
name: 'gradido_id',
length: 36,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
gradidoID: string
@Column({
name: 'alias',
length: 20,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
alias: string
@Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true })
pubKey: Buffer
@Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true })
privKey: Buffer
/*
@Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' })
email: string
*/
@OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user)
@JoinColumn({ name: 'email_id' })
emailContact: UserContact
@Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null })
emailId: number | null
@Column({
name: 'first_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
firstName: string
@Column({
name: 'last_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
lastName: string
@DeleteDateColumn()
deletedAt: Date | null
@Column({ type: 'bigint', default: 0, unsigned: true })
password: BigInt
@Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP', nullable: false })
createdAt: Date
/*
@Column({ name: 'email_checked', type: 'bool', nullable: false, default: false })
emailChecked: boolean
*/
@Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false })
language: string
@Column({ name: 'is_admin', type: 'datetime', nullable: true, default: null })
isAdmin: Date | null
@Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null })
referrerId?: number | null
@Column({
name: 'contribution_link_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
contributionLinkId?: number | null
@Column({ name: 'publisher_id', default: 0 })
publisherId: number
@Column({
type: 'text',
name: 'passphrase',
collation: 'utf8mb4_unicode_ci',
nullable: true,
default: null,
})
passphrase: string
@OneToMany(() => Contribution, (contribution) => contribution.user)
@JoinColumn({ name: 'user_id' })
contributions?: Contribution[]
@OneToMany(() => ContributionMessage, (message) => message.user)
@JoinColumn({ name: 'user_id' })
messages?: ContributionMessage[]
@OneToMany(() => UserContact, (userContact: UserContact) => userContact.user)
@JoinColumn({ name: 'user_id' })
userContacts?: UserContact[]
}

View File

@ -0,0 +1,60 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToOne,
} from 'typeorm'
import { User } from './User'
@Entity('user_contacts', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class UserContact extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({
name: 'type',
length: 100,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
type: string
@OneToOne(() => User, (user) => user.emailContact)
user: User
@Column({ name: 'user_id', type: 'int', unsigned: true, nullable: false })
userId: number
@Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' })
email: string
@Column({ name: 'email_verification_code', type: 'bigint', unsigned: true, unique: true })
emailVerificationCode: BigInt
@Column({ name: 'email_opt_in_type_id' })
emailOptInTypeId: number
@Column({ name: 'email_resend_count' })
emailResendCount: number
// @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true })
// emailHash: Buffer
@Column({ name: 'email_checked', type: 'bool', nullable: false, default: false })
emailChecked: boolean
@Column({ length: 255, unique: false, nullable: true, collation: 'utf8mb4_unicode_ci' })
phone: string
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false })
createdAt: Date
@Column({ name: 'updated_at', nullable: true, default: null, type: 'datetime' })
updatedAt: Date | null
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt: Date | null
}

View File

@ -1 +1 @@
export { ContributionMessage } from './0047-messages_tables/ContributionMessage' export { ContributionMessage } from './0048-add_is_moderator_to_contribution_messages/ContributionMessage'

View File

@ -1 +1 @@
export { User } from './0046-adapt_users_table_for_gradidoid/User' export { User } from './0049-add_user_contacts_table/User'

View File

@ -0,0 +1 @@
export { UserContact } from './0049-add_user_contacts_table/UserContact'

View File

@ -5,6 +5,7 @@ import { Migration } from './Migration'
import { Transaction } from './Transaction' import { Transaction } from './Transaction'
import { TransactionLink } from './TransactionLink' import { TransactionLink } from './TransactionLink'
import { User } from './User' import { User } from './User'
import { UserContact } from './UserContact'
import { Contribution } from './Contribution' import { Contribution } from './Contribution'
import { EventProtocol } from './EventProtocol' import { EventProtocol } from './EventProtocol'
import { ContributionMessage } from './ContributionMessage' import { ContributionMessage } from './ContributionMessage'
@ -20,4 +21,5 @@ export const entities = [
User, User,
EventProtocol, EventProtocol,
ContributionMessage, ContributionMessage,
UserContact,
] ]

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 \`contribution_messages\` ADD COLUMN \`is_moderator\` boolean NOT NULL DEFAULT false;`,
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`ALTER TABLE \`contribution_messages\` DROP COLUMN \`is_moderator\`;`)
}

View File

@ -0,0 +1,97 @@
/* MIGRATION TO ADD GRADIDO_ID
*
* This migration adds new columns to the table `users` and creates the
* new table `user_contacts`
*/
/* 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(`
CREATE TABLE IF NOT EXISTS \`user_contacts\` (
\`id\` int(10) unsigned NOT NULL AUTO_INCREMENT,
\`type\` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
\`user_id\` int(10) unsigned NOT NULL,
\`email\` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL UNIQUE,
\`email_verification_code\` bigint(20) unsigned DEFAULT NULL UNIQUE,
\`email_opt_in_type_id\` int DEFAULT NULL,
\`email_resend_count\` int DEFAULT '0',
\`email_checked\` tinyint(4) NOT NULL DEFAULT 0,
\`phone\` varchar(255) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
\`created_at\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
\`updated_at\` datetime(3) NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP(3),
\`deleted_at\` datetime(3) NULL DEFAULT NULL,
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`)
await queryFn('ALTER TABLE `users` ADD COLUMN `email_id` int(10) NULL AFTER `email`;')
// define datetime column with a precision of 3 milliseconds
await queryFn(
'ALTER TABLE `users` MODIFY COLUMN `created` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) AFTER `email_hash`;',
)
// define datetime column with a precision of 3 milliseconds
await queryFn(
'ALTER TABLE `users` MODIFY COLUMN `deletedAt` datetime(3) NULL DEFAULT NULL AFTER `last_name`;',
)
// define datetime column with a precision of 3 milliseconds
await queryFn(
'ALTER TABLE `users` MODIFY COLUMN `is_admin` datetime(3) NULL DEFAULT NULL AFTER `language`;',
)
// merge values from login_email_opt_in table with users.email in new user_contacts table
await queryFn(`
INSERT INTO user_contacts
(type, user_id, email, email_verification_code, email_opt_in_type_id, email_resend_count, email_checked, created_at, updated_at, deleted_at)
SELECT 'EMAIL', users.id, users.email, optin.verification_code, optin.email_opt_in_type_id, optin.resend_count, users.email_checked, users.created, null, users.deletedAt
FROM users LEFT JOIN
(SELECT le.id, le.user_id, le.verification_code, le.email_opt_in_type_id, le.resend_count, le.created, le.updated,
ROW_NUMBER() OVER (PARTITION BY le.user_id ORDER BY le.created DESC) AS row_num
FROM login_email_opt_in as le) AS optin ON users.id = optin.user_id AND row_num = 1;`)
// insert in users table the email_id of the new created email-contacts
const contacts = await queryFn(`SELECT c.id, c.user_id FROM user_contacts as c`)
for (const id in contacts) {
const contact = contacts[id]
await queryFn(
`UPDATE users as u SET u.email_id = "${contact.id}" WHERE u.id = "${contact.user_id}"`,
)
}
// these steps comes after verification and test
await queryFn('ALTER TABLE users DROP COLUMN email;')
await queryFn('ALTER TABLE users DROP COLUMN email_checked;')
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
// this step comes after verification and test
await queryFn('ALTER TABLE users ADD COLUMN email varchar(255) NULL AFTER privkey;')
await queryFn(
'ALTER TABLE users ADD COLUMN email_checked tinyint(4) NOT NULL DEFAULT 0 AFTER email;',
)
await queryFn(
'ALTER TABLE `users` MODIFY COLUMN `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `email_hash`;',
)
await queryFn(
'ALTER TABLE `users` MODIFY COLUMN `deletedAt` datetime NULL DEFAULT NULL AFTER `last_name`;',
)
await queryFn(
'ALTER TABLE `users` MODIFY COLUMN `is_admin` datetime NULL DEFAULT NULL AFTER `language`;',
)
// reconstruct the previous email back from contacts to users table
const contacts = await queryFn(
`SELECT c.id, c.email, c.user_id, c.email_checked FROM user_contacts as c`,
)
for (const id in contacts) {
const contact = contacts[id]
await queryFn(
`UPDATE users SET email = "${contact.email}", email_checked="${contact.email_checked}" WHERE id = "${contact.user_id}" and email_id = "${contact.id}"`,
)
}
await queryFn('ALTER TABLE users MODIFY COLUMN email varchar(255) NOT NULL UNIQUE;')
// write downgrade logic as parameter of queryFn
await queryFn(`DROP TABLE IF EXISTS user_contacts;`)
await queryFn('ALTER TABLE users DROP COLUMN email_id;')
}

View File

@ -1,6 +1,6 @@
{ {
"name": "gradido-database", "name": "gradido-database",
"version": "1.11.0", "version": "1.12.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

@ -26,10 +26,11 @@ COMMUNITY_REDEEM_CONTRIBUTION_URL=https://stage1.gradido.net/redeem/CL-{code}
COMMUNITY_DESCRIPTION="Gradido Development Stage1 Test Community" COMMUNITY_DESCRIPTION="Gradido Development Stage1 Test Community"
# backend # backend
BACKEND_CONFIG_VERSION=v9.2022-07-07 BACKEND_CONFIG_VERSION=v10.2022-09-20
JWT_EXPIRES_IN=10m JWT_EXPIRES_IN=10m
GDT_API_URL=https://gdt.gradido.net GDT_API_URL=https://gdt.gradido.net
ENV_NAME=stage1
TYPEORM_LOGGING_RELATIVE_PATH=../deployment/bare_metal/log/typeorm.backend.log TYPEORM_LOGGING_RELATIVE_PATH=../deployment/bare_metal/log/typeorm.backend.log
@ -40,6 +41,8 @@ KLICKTIPP_APIKEY_DE=
KLICKTIPP_APIKEY_EN= KLICKTIPP_APIKEY_EN=
EMAIL=true EMAIL=true
EMAIL_TEST_MODUS=false
EMAIL_TEST_RECEIVER=test_team@gradido.net
EMAIL_USERNAME=peter@lustig.de EMAIL_USERNAME=peter@lustig.de
EMAIL_SENDER=peter@lustig.de EMAIL_SENDER=peter@lustig.de
EMAIL_PASSWORD=1234 EMAIL_PASSWORD=1234
@ -61,7 +64,7 @@ EVENT_PROTOCOL_DISABLED=false
DATABASE_CONFIG_VERSION=v1.2022-03-18 DATABASE_CONFIG_VERSION=v1.2022-03-18
# frontend # frontend
FRONTEND_CONFIG_VERSION=v2.2022-04-07 FRONTEND_CONFIG_VERSION=v3.2022-09-16
GRAPHQL_URI=https://stage1.gradido.net/graphql GRAPHQL_URI=https://stage1.gradido.net/graphql
ADMIN_AUTH_URL=https://stage1.gradido.net/admin/authenticate?token={token} ADMIN_AUTH_URL=https://stage1.gradido.net/admin/authenticate?token={token}
@ -77,6 +80,8 @@ META_KEYWORDS_DE="Grundeinkommen, Währung, Dankbarkeit, Schenk-Ökonomie, Natü
META_KEYWORDS_EN="Basic Income, Currency, Gratitude, Gift Economy, Natural Economy of Life, Economy, Ecology, Potential Development, Giving and Thanking, Cycle of Life, Monetary System" META_KEYWORDS_EN="Basic Income, Currency, Gratitude, Gift Economy, Natural Economy of Life, Economy, Ecology, Potential Development, Giving and Thanking, Cycle of Life, Monetary System"
META_AUTHOR="Bernd Hückstädt - Gradido-Akademie" META_AUTHOR="Bernd Hückstädt - Gradido-Akademie"
SUPPORT_MAIL=support@supportmail.com
# admin # admin
ADMIN_CONFIG_VERSION=v1.2022-03-18 ADMIN_CONFIG_VERSION=v1.2022-03-18

View File

@ -4,6 +4,12 @@
# How to do this is described in detail in [setup.md](./setup.md) # How to do this is described in detail in [setup.md](./setup.md)
# Find current directory & configure paths # Find current directory & configure paths
## For manualy use in terminal
## set -o allexport
## SCRIPT_DIR=$(pwd)
## PROJECT_ROOT=$SCRIPT_DIR/../..
## set +o allexport
# Use here in script
set -o allexport set -o allexport
SCRIPT_PATH=$(realpath $0) SCRIPT_PATH=$(realpath $0)
SCRIPT_DIR=$(dirname $SCRIPT_PATH) SCRIPT_DIR=$(dirname $SCRIPT_PATH)
@ -75,7 +81,7 @@ pm2 startup
sudo apt-get install -y certbot sudo apt-get install -y certbot
sudo apt-get install -y python3-certbot-nginx sudo apt-get install -y python3-certbot-nginx
sudo certbot sudo certbot
> Enter email address (used for urgent renewal and security notices) > support@gradido.net > Enter email address (used for urgent renewal and security notices) > e.g. support@supportmail.com
> Please read the Terms of Service at > Y > Please read the Terms of Service at > Y
> Would you be willing, once your first certificate is successfully issued, to > N > Would you be willing, once your first certificate is successfully issued, to > N
> No names were found in your configuration files. Please enter in your domain > stage1.gradido.net > No names were found in your configuration files. Please enter in your domain > stage1.gradido.net
@ -90,7 +96,7 @@ sudo certbot
# Install logrotate # Install logrotate
sudo apt-get install -y logrotate sudo apt-get install -y logrotate
envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $SCRIPT_DIR/logrotate/gradido.conf.template > $SCRIPT_DIR/logrotate/gradido.conf envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $SCRIPT_DIR/logrotate/gradido.conf.template > $SCRIPT_DIR/logrotate/gradido.conf
sudo mv $SCRIPT_DIR/logrotate/gradido.conf /etc/logrotate.d/gradido.conf sudo cp $SCRIPT_DIR/logrotate/gradido.conf.template /etc/logrotate.d/gradido.conf
sudo chown root:root /etc/logrotate.d/gradido.conf sudo chown root:root /etc/logrotate.d/gradido.conf
# Install mysql autobackup # Install mysql autobackup
@ -131,6 +137,10 @@ envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/frontend/.env
# Configure admin # Configure admin
envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/admin/.env.template > $PROJECT_ROOT/admin/.env envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/admin/.env.template > $PROJECT_ROOT/admin/.env
# create cronjob to delete yarn output in /tmp
# crontab -e
# hourly job: 0 * * * * find /tmp -name "yarn--*" -cmin +60 -exec rm -r {} \; > /dev/null
# daily job: 0 4 * * * find /tmp -name "yarn--*" -ctime +1 -exec rm -r {} \; > /dev/null
# Start gradido # Start gradido
# Note: on first startup some errors will occur - nothing serious # Note: on first startup some errors will occur - nothing serious
./start.sh ./start.sh

Some files were not shown because too many files have changed in this diff Show More