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).
#### [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)
> 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)
- 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)

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -1,7 +1,44 @@
<template>
<div class="component-creation-transaction-list">
<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>
</template>
<script>
@ -13,14 +50,37 @@ export default {
},
data() {
return {
items: [],
rows: 0,
currentPage: 1,
perPage: 10,
fields: [
{
key: 'creationDate',
label: this.$t('transactionlist.date'),
key: 'createdAt',
label: this.$t('transactionlist.submitted'),
formatter: (value, key, item) => {
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',
label: this.$t('transactionlist.amount'),
@ -28,23 +88,8 @@ export default {
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: 'balanceDate',
label: this.$t('transactionlist.balanceDate'),
formatter: (value, key, item) => {
return this.$d(new Date(value))
},
},
],
items: [],
}
},
methods: {
@ -53,14 +98,15 @@ export default {
.query({
query: creationTransactionList,
variables: {
currentPage: 1,
pageSize: 25,
currentPage: this.currentPage,
pageSize: this.perPage,
order: 'DESC',
userId: parseInt(this.userId),
},
})
.then((result) => {
this.items = result.data.creationTransactionList
this.rows = result.data.creationTransactionList.contributionCount
this.items = result.data.creationTransactionList.contributionList
})
.catch((error) => {
this.toastError(error.message)
@ -70,5 +116,10 @@ export default {
created() {
this.getTransactions()
},
watch: {
currentPage() {
this.getTransactions()
},
},
}
</script>

View File

@ -12,6 +12,7 @@
</b-button>
</template>
<template #cell(editCreation)="row">
<div v-if="$store.state.moderator.id !== row.item.userId">
<b-button
v-if="row.item.moderator"
variant="info"
@ -21,11 +22,32 @@
>
<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 #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-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 #row-details="row">
<row-details
@ -33,10 +55,10 @@
type="show-creation"
slotName="show-creation"
:index="0"
@row-toggle-details="rowToggleDetails"
@row-toggle-details="rowToggleDetails(row, 0)"
>
<template #show-creation>
<div>
<div v-if="row.item.moderator">
<edit-creation-formular
type="singleCreation"
:creation="row.item.creation"
@ -44,6 +66,12 @@
:row="row"
:creationUserData="creationUserData"
@update-creation-data="updateCreationData"
/>
</div>
<div v-else>
<contribution-messages-list
:contributionId="row.item.id"
@update-state="updateState"
@update-user-data="updateUserData"
/>
</div>
@ -58,6 +86,7 @@
import { toggleRowDetails } from '../../mixins/toggleRowDetails'
import RowDetails from '../RowDetails.vue'
import EditCreationFormular from '../EditCreationFormular.vue'
import ContributionMessagesList from '../ContributionMessages/ContributionMessagesList.vue'
export default {
name: 'OpenCreationsTable',
@ -65,6 +94,7 @@ export default {
components: {
EditCreationFormular,
RowDetails,
ContributionMessagesList,
},
props: {
items: {
@ -98,6 +128,9 @@ export default {
updateUserData(rowItem, newCreation) {
rowItem.creation = newCreation
},
updateState(id) {
this.$emit('update-state', id)
},
},
}
</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
userId: $userId
) {
contributionCount
contributionList {
id
amount
balanceDate
creationDate
createdAt
confirmedAt
contributionDate
memo
linkedUser {
firstName
lastName
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
firstName
lastName
userId
email
amount
memo
date
moderator
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",
"create": "Anlegen",
"cycle": "Zyklus",
"deleteNow": "Automatische Creations wirklich löschen?",
"deleted": "Automatische Schöpfung gelöscht!",
"deleteNow": "Automatische Creations '{name}' wirklich löschen?",
"maximumAmount": "maximaler Betrag",
"maxPerCycle": "Wiederholungen",
"memo": "Nachricht",
@ -69,15 +70,32 @@
},
"short_hash": "({shortHash})"
},
"form": {
"cancel": "Abbrechen",
"submit": "Senden"
},
"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",
"lastname": "Nachname",
"math": {
"colon": ":",
"equals": "=",
"exclaim": "!",
"pipe": "|",
"plus": "+"
},
"message": {
"request": "Die Anfrage wurde gesendet."
},
"moderator": "Moderator",
"multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.",
"name": "Name",
@ -126,10 +144,11 @@
},
"transactionlist": {
"amount": "Betrag",
"balanceDate": "Schöpfungsdatum",
"community": "Gemeinschaft",
"date": "Datum",
"confirmed": "Bestätigt",
"memo": "Nachricht",
"period": "Zeitraum",
"state": "Status",
"submitted": "Eingereicht",
"title": "Alle geschöpften Transaktionen für den Nutzer"
},
"undelete_user": "Nutzer wiederherstellen",

View File

@ -7,7 +7,8 @@
"contributionLinks": "Contribution Links",
"create": "Create",
"cycle": "Cycle",
"deleteNow": "Do you really delete automatic creations?",
"deleted": "Automatic creation deleted!",
"deleteNow": "Do you really delete automatic creations '{name}'?",
"maximumAmount": "Maximum amount",
"maxPerCycle": "Repetition",
"memo": "Memo",
@ -69,15 +70,32 @@
},
"short_hash": "({shortHash})"
},
"form": {
"cancel": "Cancel",
"submit": "Send"
},
"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",
"lastname": "Lastname",
"math": {
"colon": ":",
"equals": "=",
"exclaim": "!",
"pipe": "|",
"plus": "+"
},
"message": {
"request": "Request has been sent."
},
"moderator": "Moderator",
"multiple_creation_text": "Please select one or more members for which you would like to perform creations.",
"name": "Name",
@ -126,10 +144,11 @@
},
"transactionlist": {
"amount": "Amount",
"balanceDate": "Creation date",
"community": "Community",
"date": "Date",
"confirmed": "Confirmed",
"memo": "Message",
"period": "Period",
"state": "State",
"submitted": "Submitted",
"title": "All creation-transactions for the user"
},
"undelete_user": "Undelete User",

View File

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

View File

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

View File

@ -28,7 +28,11 @@
</b-link>
</b-card-text>
</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" />
</div>
</template>
@ -89,7 +93,6 @@ export default {
this.$apollo
.query({
query: communityStatistics,
fetchPolicy: 'network-only',
})
.then((result) => {
this.statistics.totalUsers = result.data.communityStatistics.totalUsers

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "gradido-backend",
"version": "1.11.0",
"version": "1.12.1",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts",
"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",
"lint": "eslint --max-warnings=0 --ext .js,.ts .",
"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": {
"@types/jest": "^27.0.2",

View File

@ -33,6 +33,8 @@ export enum RIGHTS {
LIST_CONTRIBUTION_LINKS = 'LIST_CONTRIBUTION_LINKS',
COMMUNITY_STATISTICS = 'COMMUNITY_STATISTICS',
SEARCH_ADMIN_USERS = 'SEARCH_ADMIN_USERS',
CREATE_CONTRIBUTION_MESSAGE = 'CREATE_CONTRIBUTION_MESSAGE',
LIST_ALL_CONTRIBUTION_MESSAGES = 'LIST_ALL_CONTRIBUTION_MESSAGES',
// Admin
SEARCH_USERS = 'SEARCH_USERS',
SET_USER_ROLE = 'SET_USER_ROLE',
@ -50,4 +52,5 @@ export enum RIGHTS {
CREATE_CONTRIBUTION_LINK = 'CREATE_CONTRIBUTION_LINK',
DELETE_CONTRIBUTION_LINK = 'DELETE_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.LIST_CONTRIBUTION_LINKS,
RIGHTS.COMMUNITY_STATISTICS,
RIGHTS.CREATE_CONTRIBUTION_MESSAGE,
RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES,
])
export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights

View File

@ -10,14 +10,14 @@ Decimal.set({
})
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
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v9.2022-07-07',
EXPECTED: 'v10.2022-09-20',
CURRENT: '',
},
}
@ -67,6 +67,8 @@ const loginServer = {
const email = {
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_SENDER: process.env.EMAIL_SENDER || 'info@gradido.net',
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 EventInactiveAccount extends EventBasicUserId {}
export class EventSendConfirmationEmail extends EventBasicUserId {}
export class EventSendAccountMultiRegistrationEmail extends EventBasicUserId {}
export class EventConfirmationEmail extends EventBasicUserId {}
export class EventRegisterEmailKlicktipp extends EventBasicUserId {}
export class EventLogin extends EventBasicUserId {}
@ -113,6 +114,15 @@ export class Event {
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 {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.CONFIRM_EMAIL

View File

@ -5,6 +5,7 @@ export enum EventProtocolType {
REDEEM_REGISTER = 'REDEEM_REGISTER',
INACTIVE_ACCOUNT = 'INACTIVE_ACCOUNT',
SEND_CONFIRMATION_EMAIL = 'SEND_CONFIRMATION_EMAIL',
SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL = 'SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL',
CONFIRM_EMAIL = 'CONFIRM_EMAIL',
REGISTER_EMAIL_KLICKTIPP = 'REGISTER_EMAIL_KLICKTIPP',
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 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 {
const user = await userRepository.findByPubkeyHex(context.pubKey)
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 Decimal from 'decimal.js-light'
import { Contribution as dbContribution } from '@entity/Contribution'
import { User } from './User'
import { User } from '@entity/User'
@ObjectType()
export class Contribution {
constructor(contribution: dbContribution, user: User) {
constructor(contribution: dbContribution, user?: User | null) {
this.id = contribution.id
this.firstName = user ? user.firstName : null
this.lastName = user ? user.lastName : null
@ -16,6 +16,8 @@ export class Contribution {
this.confirmedAt = contribution.confirmedAt
this.confirmedBy = contribution.confirmedBy
this.contributionDate = contribution.contributionDate
this.state = contribution.contributionStatus
this.messagesCount = contribution.messages ? contribution.messages.length : 0
}
@Field(() => Number)
@ -47,6 +49,12 @@ export class Contribution {
@Field(() => Date)
contributionDate: Date
@Field(() => Number)
messagesCount: number
@Field(() => String)
state: string
}
@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()
export class UnconfirmedContribution {
constructor(contribution: Contribution, user: User, creations: Decimal[]) {
constructor(contribution: Contribution, user: User | undefined, creations: Decimal[]) {
this.id = contribution.id
this.userId = contribution.userId
this.amount = contribution.amount
@ -13,8 +13,11 @@ export class UnconfirmedContribution {
this.date = contribution.contributionDate
this.firstName = user ? user.firstName : ''
this.lastName = user ? user.lastName : ''
this.email = user ? user.email : ''
this.email = user ? user.emailContact.email : ''
this.moderator = contribution.moderatorId
this.creation = creations
this.state = contribution.contributionStatus
this.messageCount = contribution.messages ? contribution.messages.length : 0
}
@Field(() => String)
@ -46,4 +49,10 @@ export class UnconfirmedContribution {
@Field(() => [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 Decimal from 'decimal.js-light'
import { FULL_CREATION_AVAILABLE } from '../resolver/const/const'
import { UserContact } from './UserContact'
@ObjectType()
export class User {
@ -10,12 +11,16 @@ export class User {
this.id = user.id
this.gradidoID = user.gradidoID
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.lastName = user.lastName
this.deletedAt = user.deletedAt
this.createdAt = user.createdAt
this.emailChecked = user.emailChecked
this.language = user.language
this.publisherId = user.publisherId
this.isAdmin = user.isAdmin
@ -34,12 +39,18 @@ export class User {
gradidoID: string
@Field(() => String, { nullable: true })
alias: string
alias?: string
@Field(() => Number, { nullable: true })
emailId: number | null
// TODO privacy issue here
@Field(() => String)
@Field(() => String, { nullable: true })
email: string
@Field(() => UserContact)
emailContact: UserContact
@Field(() => String, { nullable: true })
firstName: string | null

View File

@ -6,11 +6,11 @@ import { User } from '@entity/User'
export class UserAdmin {
constructor(user: User, creation: Decimal[], hasElopage: boolean, emailConfirmationSend: string) {
this.userId = user.id
this.email = user.email
this.email = user.emailContact.email
this.firstName = user.firstName
this.lastName = user.lastName
this.creation = creation
this.emailChecked = user.emailChecked
this.emailChecked = user.emailContact.emailChecked
this.hasElopage = hasElopage
this.deletedAt = user.deletedAt
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 { Transaction as DbTransaction } from '@entity/Transaction'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail'
// mock account activation email to avoid console spam
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 testEnv: any
@ -1117,7 +1126,9 @@ describe('AdminResolver', () => {
}),
).resolves.toEqual(
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].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', () => {
@ -1493,6 +1518,7 @@ describe('AdminResolver', () => {
)
await expect(r2).resolves.toEqual(
expect.objectContaining({
// data: { confirmContribution: true },
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 {
getCustomRepository,
IsNull,
Not,
ObjectLiteral,
getConnection,
In,
MoreThan,
@ -17,6 +15,7 @@ import { AdminCreateContributions } from '@model/AdminCreateContributions'
import { AdminUpdateContribution } from '@model/AdminUpdateContribution'
import { ContributionLink } from '@model/ContributionLink'
import { ContributionLinkList } from '@model/ContributionLinkList'
import { Contribution } from '@model/Contribution'
import { RIGHTS } from '@/auth/RIGHTS'
import { UserRepository } from '@repository/User'
import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs'
@ -25,14 +24,11 @@ import SearchUsersArgs from '@arg/SearchUsersArgs'
import ContributionLinkArgs from '@arg/ContributionLinkArgs'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { Transaction } from '@model/Transaction'
import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { TransactionRepository } from '@repository/Transaction'
import { calculateDecay } from '@/util/decay'
import { Contribution } from '@entity/Contribution'
import { Contribution as DbContribution } from '@entity/Contribution'
import { hasElopageBuys } from '@/util/hasElopageBuys'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User as dbUser } from '@entity/User'
import { User } from '@model/User'
import { TransactionTypeId } from '@enum/TransactionTypeId'
@ -43,8 +39,7 @@ import { Decay } from '@model/Decay'
import Paginated from '@arg/Paginated'
import TransactionLinkFilters from '@arg/TransactionLinkFilters'
import { Order } from '@enum/Order'
import { communityUser } from '@/util/communityUser'
import { checkOptInCode, activationLink, printTimeDuration } from './UserResolver'
import { findUserByEmail, activationLink, printTimeDuration } from './UserResolver'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver'
import CONFIG from '@/config'
@ -62,6 +57,14 @@ import {
MEMO_MAX_CHARS,
MEMO_MIN_CHARS,
} 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_UNKNOWN = 3 // elopage?
@ -75,24 +78,12 @@ export class AdminResolver {
{ searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs,
): Promise<SearchUsersResult> {
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 = [
'id',
'firstName',
'lastName',
'email',
'emailChecked',
'emailId',
'emailContact',
'deletedAt',
'isAdmin',
]
@ -101,7 +92,7 @@ export class AdminResolver {
return 'user.' + fieldName
}),
searchText,
filterCriteria,
filters,
currentPage,
pageSize,
)
@ -118,32 +109,18 @@ export class AdminResolver {
const adminUsers = await Promise.all(
users.map(async (user) => {
let emailConfirmationSend = ''
if (!user.emailChecked) {
const emailOptIn = await LoginEmailOptIn.findOne(
{
userId: user.id,
},
{
order: {
updatedAt: 'DESC',
createdAt: 'DESC',
},
select: ['updatedAt', 'createdAt'],
},
)
if (emailOptIn) {
if (emailOptIn.updatedAt) {
emailConfirmationSend = emailOptIn.updatedAt.toISOString()
if (!user.emailContact.emailChecked) {
if (user.emailContact.updatedAt) {
emailConfirmationSend = user.emailContact.updatedAt.toISOString()
} else {
emailConfirmationSend = emailOptIn.createdAt.toISOString()
}
emailConfirmationSend = user.emailContact.createdAt.toISOString()
}
}
const userCreations = creations.find((c) => c.id === user.id)
const adminUser = new UserAdmin(
user,
userCreations ? userCreations.creations : FULL_CREATION_AVAILABLE,
await hasElopageBuys(user.email),
await hasElopageBuys(user.emailContact.email),
emailConfirmationSend,
)
return adminUser
@ -239,24 +216,39 @@ export class AdminResolver {
@Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs,
@Ctx() context: Context,
): Promise<Decimal[]> {
const user = await dbUser.findOne({ email }, { withDeleted: true })
if (!user) {
logger.info(
`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}`)
}
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.')
}
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')
}
const moderator = getUser(context)
logger.trace('moderator: ', moderator.id)
const creations = await getUserCreation(user.id)
logger.trace('creations', creations)
const creations = await getUserCreation(emailContact.userId)
logger.trace('creations:', creations)
const creationDateObj = new Date(creationDate)
logger.trace('creationDateObj:', creationDateObj)
validateContribution(creations, amount, creationDateObj)
const contribution = Contribution.create()
contribution.userId = user.id
const contribution = DbContribution.create()
contribution.userId = emailContact.userId
contribution.amount = amount
contribution.createdAt = new Date()
contribution.contributionDate = creationDateObj
@ -266,8 +258,8 @@ export class AdminResolver {
contribution.contributionStatus = ContributionStatus.PENDING
logger.trace('contribution to save', contribution)
await Contribution.save(contribution)
return getUserCreation(user.id)
await DbContribution.save(contribution)
return getUserCreation(emailContact.userId)
}
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS])
@ -303,29 +295,43 @@ export class AdminResolver {
@Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs,
@Ctx() context: Context,
): 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) {
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) {
logger.error(`User was deleted (${email})`)
throw new Error(`User was deleted (${email})`)
}
const moderator = getUser(context)
const contributionToUpdate = await Contribution.findOne({
const contributionToUpdate = await DbContribution.findOne({
where: { id, confirmedAt: IsNull() },
})
if (!contributionToUpdate) {
logger.error('No contribution found to given id.')
throw new Error('No contribution found to given 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')
}
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.')
}
@ -343,7 +349,7 @@ export class AdminResolver {
contributionToUpdate.moderatorId = moderator.id
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
await Contribution.save(contributionToUpdate)
await DbContribution.save(contributionToUpdate)
const result = new AdminUpdateContribution()
result.amount = amount
result.memo = contributionToUpdate.memo
@ -357,39 +363,44 @@ export class AdminResolver {
@Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS])
@Query(() => [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) {
return []
}
const userIds = contributions.map((p) => p.userId)
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) => {
const user = users.find((u) => u.id === contribution.userId)
const creation = userCreations.find((c) => c.id === contribution.userId)
return {
id: contribution.id,
userId: contribution.userId,
date: contribution.contributionDate,
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,
}
return new UnconfirmedContribution(
contribution,
user,
creation ? creation.creations : FULL_CREATION_AVAILABLE,
)
})
}
@Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION])
@Mutation(() => Boolean)
async adminDeleteContribution(@Arg('id', () => Int) id: number): Promise<boolean> {
const contribution = await Contribution.findOne(id)
const contribution = await DbContribution.findOne(id)
if (!contribution) {
logger.error(`Contribution not found for given id: ${id}`)
throw new Error('Contribution not found for given id.')
}
contribution.contributionStatus = ContributionStatus.DELETED
@ -404,17 +415,24 @@ export class AdminResolver {
@Arg('id', () => Int) id: number,
@Ctx() context: Context,
): Promise<boolean> {
const contribution = await Contribution.findOne(id)
const contribution = await DbContribution.findOne(id)
if (!contribution) {
logger.error(`Contribution not found for given id: ${id}`)
throw new Error('Contribution not found to given id.')
}
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')
const user = await dbUser.findOneOrFail({ id: contribution.userId }, { withDeleted: true })
if (user.deletedAt) throw new Error('This user was deleted. Cannot confirm a contribution.')
}
const user = await dbUser.findOneOrFail(
{ 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)
validateContribution(creations, contribution.amount, contribution.contributionDate)
@ -422,7 +440,7 @@ export class AdminResolver {
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED')
try {
const lastTransaction = await queryRunner.manager
.createQueryBuilder()
@ -462,10 +480,20 @@ export class AdminResolver {
contribution.confirmedBy = moderatorUser.id
contribution.transactionId = transaction.id
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()
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) {
await queryRunner.rollbackTransaction()
logger.error(`Creation was not successful: ${e}`)
@ -477,56 +505,64 @@ export class AdminResolver {
}
@Authorized([RIGHTS.CREATION_TRANSACTION_LIST])
@Query(() => [Transaction])
@Query(() => ContributionListResult)
async creationTransactionList(
@Args()
{ currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
@Arg('userId', () => Int) userId: number,
): Promise<Transaction[]> {
): Promise<ContributionListResult> {
const offset = (currentPage - 1) * pageSize
const transactionRepository = getCustomRepository(TransactionRepository)
const [userTransactions] = await transactionRepository.findByUserPaged(
userId,
pageSize,
offset,
order,
true,
)
const [contributionResult, count] = await getConnection()
.createQueryBuilder()
.select('c')
.from(DbContribution, 'c')
.leftJoinAndSelect('c.user', 'u')
.where(`user_id = ${userId}`)
.limit(pageSize)
.offset(offset)
.orderBy('c.created_at', order)
.getManyAndCount()
const user = await dbUser.findOneOrFail({ id: userId })
return userTransactions.map((t) => new Transaction(t, new User(user), communityUser))
return new ContributionListResult(
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])
@Mutation(() => Boolean)
async sendActivationEmail(@Arg('email') email: string): Promise<boolean> {
email = email.trim().toLowerCase()
const user = await dbUser.findOneOrFail({ email: email })
// can be both types: REGISTER and RESET_PASSWORD
let optInCode = await LoginEmailOptIn.findOne({
where: { userId: user.id },
order: { updatedAt: 'DESC' },
})
optInCode = await checkOptInCode(optInCode, user)
// const user = await dbUser.findOne({ id: emailContact.userId })
const user = await findUserByEmail(email)
if (!user) {
logger.error(`Could not find User to emailContact: ${email}`)
throw new Error(`Could not find User to emailContact: ${email}`)
}
if (user.deletedAt) {
logger.error(`User with emailContact: ${email} is deleted.`)
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
const emailSent = await sendAccountActivationEmail({
link: activationLink(optInCode),
link: activationLink(emailContact.emailVerificationCode),
firstName: user.firstName,
lastName: user.lastName,
email,
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
if (!emailSent) {
// eslint-disable-next-line no-console
console.log(`Account confirmation link: ${activationLink}`)
logger.info(`Account confirmation link: ${activationLink}`)
}
*/
return true
}
@ -696,4 +732,73 @@ export class AdminResolver {
logger.debug(`updateContributionLink successful!`)
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 { Contribution, ContributionListResult } from '@model/Contribution'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { User } from '@model/User'
import { validateContribution, getUserCreation, updateCreations } from './util/creations'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
@ -90,19 +89,23 @@ export class ContributionResolver {
userId: number
confirmedBy?: FindOperator<number> | null
} = { userId: user.id }
if (filterConfirmed) where.confirmedBy = IsNull()
const [contributions, count] = await dbContribution.findAndCount({
where,
order: {
createdAt: order,
},
withDeleted: true,
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
const [contributions, count] = await getConnection()
.createQueryBuilder()
.select('c')
.from(dbContribution, 'c')
.leftJoinAndSelect('c.messages', 'm')
.where(where)
.orderBy('c.createdAt', order)
.limit(pageSize)
.offset((currentPage - 1) * pageSize)
.getManyAndCount()
return new ContributionListResult(
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()
return new ContributionListResult(
count,
dbContributions.map(
(contribution) => new Contribution(contribution, new User(contribution.user)),
),
dbContributions.map((contribution) => new Contribution(contribution, contribution.user)),
)
}

View File

@ -20,7 +20,7 @@ export class GdtResolver {
try {
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) {
throw new Error(resultGDT.data)
@ -37,7 +37,7 @@ export class GdtResolver {
const user = getUser(context)
try {
const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, {
email: user.email,
email: user.emailContact.email,
})
if (!resultGDTSum.success) {
throw new Error('Call not successful')

View File

@ -7,48 +7,47 @@ import { getConnection } from '@dbTools/typeorm'
import Decimal from 'decimal.js-light'
import { calculateDecay } from '@/util/decay'
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
@Resolver()
export class StatisticsResolver {
@Authorized([RIGHTS.COMMUNITY_STATISTICS])
@Query(() => CommunityStatistics)
async communityStatistics(): Promise<CommunityStatistics> {
const allUsers = await DbUser.find({ withDeleted: true })
let totalUsers = 0
let activeUsers = 0
let deletedUsers = 0
const allUsers = await DbUser.count({ withDeleted: true })
const totalUsers = await DbUser.count()
const deletedUsers = allUsers - totalUsers
let totalGradidoAvailable: Decimal = new Decimal(0)
let totalGradidoUnbookedDecayed: Decimal = new Decimal(0)
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,
const queryRunner = getConnection().createQueryRunner()
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 queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
})
const { totalGradidoCreated } = await queryRunner.manager
.createQueryBuilder()

View File

@ -178,7 +178,7 @@ export class TransactionLinkResolver {
logger.info('redeem contribution link...')
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('SERIALIZABLE')
await queryRunner.startTransaction('REPEATABLE READ')
try {
const contributionLink = await queryRunner.manager
.createQueryBuilder()
@ -283,7 +283,10 @@ export class TransactionLinkResolver {
return true
} else {
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) {
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 { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
import { findUserByEmail } from './UserResolver'
import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed'
export const executeTransaction = async (
amount: Decimal,
@ -78,7 +80,7 @@ export const executeTransaction = async (
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
await queryRunner.startTransaction('REPEATABLE READ')
logger.debug(`open Transaction to write...`)
try {
// transaction
@ -148,12 +150,24 @@ export const executeTransaction = async (
senderLastName: sender.lastName,
recipientFirstName: recipient.firstName,
recipientLastName: recipient.lastName,
email: recipient.email,
senderEmail: sender.email,
email: recipient.emailContact.email,
senderEmail: sender.emailContact.email,
amount,
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`)
return true
}
@ -171,7 +185,7 @@ export class TransactionResolver {
const user = getUser(context)
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
const lastTransaction = await dbTransaction.findOne(
@ -293,16 +307,25 @@ export class TransactionResolver {
}
// 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) {
logger.error(`recipient not known: email=${email}`)
throw new Error('recipient not known')
logger.error(`unknown recipient to UserContact: email=${email}`)
throw new Error('unknown recipient')
}
if (recipientUser.deletedAt) {
logger.error(`The recipient account was deleted: recipientUser=${recipientUser}`)
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}`)
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/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 { 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 { GraphQLError } from 'graphql'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User } from '@entity/User'
import CONFIG from '@/config'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
@ -15,13 +21,19 @@ import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegi
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
import { printTimeDuration, activationLink } from './UserResolver'
import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
// import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
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 { validate as validateUUID, version as versionUUID } from 'uuid'
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'
@ -82,7 +94,7 @@ describe('UserResolver', () => {
}
let result: any
let emailOptIn: string
let emailVerificationCode: string
let user: User[]
beforeAll(async () => {
@ -101,11 +113,11 @@ describe('UserResolver', () => {
})
describe('valid input data', () => {
let loginEmailOptIn: LoginEmailOptIn[]
// let loginEmailOptIn: LoginEmailOptIn[]
beforeAll(async () => {
user = await User.find()
loginEmailOptIn = await LoginEmailOptIn.find()
emailOptIn = loginEmailOptIn[0].verificationCode.toString()
user = await User.find({ relations: ['emailContact'] })
// loginEmailOptIn = await LoginEmailOptIn.find()
emailVerificationCode = user[0].emailContact.emailVerificationCode.toString()
})
describe('filling all tables', () => {
@ -115,15 +127,16 @@ describe('UserResolver', () => {
id: expect.any(Number),
gradidoID: expect.any(String),
alias: null,
email: 'peter@lustig.de',
emailContact: expect.any(UserContact), // 'peter@lustig.de',
emailId: expect.any(Number),
firstName: 'Peter',
lastName: 'Lustig',
password: '0',
pubKey: null,
privKey: null,
emailHash: expect.any(Buffer),
// emailHash: expect.any(Buffer),
createdAt: expect.any(Date),
emailChecked: false,
// emailChecked: false,
passphrase: expect.any(String),
language: 'de',
isAdmin: null,
@ -139,18 +152,21 @@ describe('UserResolver', () => {
expect(verUUID).toEqual(4)
})
it('creates an email optin', () => {
expect(loginEmailOptIn).toEqual([
{
it('creates an email contact', () => {
expect(user[0].emailContact).toEqual({
id: expect.any(Number),
type: UserContactType.USER_CONTACT_EMAIL,
userId: user[0].id,
verificationCode: expect.any(String),
emailOptInTypeId: 1,
email: 'peter@lustig.de',
emailChecked: false,
emailVerificationCode: expect.any(String),
emailOptInTypeId: OptInType.EMAIL_OPT_IN_REGISTER,
emailResendCount: 0,
phone: null,
createdAt: expect.any(Date),
resendCount: 0,
updatedAt: expect.any(Date),
},
])
deletedAt: null,
updatedAt: null,
})
})
})
})
@ -159,7 +175,7 @@ describe('UserResolver', () => {
it('sends an account activation email', () => {
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g,
emailOptIn,
emailVerificationCode,
).replace(/{code}/g, '')
expect(sendAccountActivationEmail).toBeCalledWith({
link: activationLink,
@ -169,6 +185,15 @@ describe('UserResolver', () => {
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', () => {
@ -208,13 +233,13 @@ describe('UserResolver', () => {
mutation: createUser,
variables: { ...variables, email: 'bibi@bloxberg.de', language: 'it' },
})
await expect(User.find()).resolves.toEqual(
expect.arrayContaining([
await expect(
UserContact.findOne({ email: 'bibi@bloxberg.de' }, { relations: ['user'] }),
).resolves.toEqual(
expect.objectContaining({
email: 'bibi@bloxberg.de',
language: 'de',
user: expect.objectContaining({ language: 'de' }),
}),
]),
)
})
})
@ -225,10 +250,12 @@ describe('UserResolver', () => {
mutation: createUser,
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.objectContaining({
emailContact: expect.objectContaining({
email: 'raeuber@hotzenplotz.de',
}),
publisherId: null,
}),
]),
@ -237,38 +264,158 @@ describe('UserResolver', () => {
})
describe('redeem codes', () => {
describe('contribution link', () => {
let result: any
let link: ContributionLink
describe('contribution link', () => {
beforeAll(async () => {
// activate account of admin Peter Lustig
await mutate({
mutation: setPassword,
variables: { code: emailOptIn, password: 'Aa12345_' },
variables: { code: emailVerificationCode, password: 'Aa12345_' },
})
// make Peter Lustig Admin
const peter = await User.findOneOrFail({ id: user[0].id })
peter.isAdmin = new Date()
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
link = await contributionLinkFactory(testEnv, {
name: 'Dokumenta 2022',
memo: 'Vielen Dank für deinen Besuch bei der Dokumenta 2022',
amount: 200,
validFrom: new Date(2022, 5, 18),
validTo: new Date(2022, 8, 25),
validFrom: actualDate,
validTo: futureDate,
})
resetToken()
await mutate({
result = await mutate({
mutation: createUser,
variables: { ...variables, email: 'ein@besucher.de', redeemCode: 'CL-' + link.code },
})
})
afterAll(async () => {
await cleanDB()
})
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({
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,
}),
)
})
})
@ -313,20 +460,23 @@ bei Gradidio sei dabei!`,
}
let result: any
let emailOptIn: string
let emailVerificationCode: string
describe('valid optin code and valid password', () => {
let newUser: any
let newUser: User
beforeAll(async () => {
await mutate({ mutation: createUser, variables: createUserVariables })
const loginEmailOptIn = await LoginEmailOptIn.find()
emailOptIn = loginEmailOptIn[0].verificationCode.toString()
const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email })
emailVerificationCode = emailContact.emailVerificationCode.toString()
result = await mutate({
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 () => {
@ -334,11 +484,11 @@ bei Gradidio sei dabei!`,
})
it('sets email checked to true', () => {
expect(newUser[0].emailChecked).toBeTruthy()
expect(newUser.emailContact.emailChecked).toBeTruthy()
})
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', () => {
beforeAll(async () => {
await mutate({ mutation: createUser, variables: createUserVariables })
const loginEmailOptIn = await LoginEmailOptIn.find()
emailOptIn = loginEmailOptIn[0].verificationCode.toString()
const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email })
emailVerificationCode = emailContact.emailVerificationCode.toString()
result = await mutate({
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', () => {
@ -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', () => {
beforeAll(async () => {
jest.clearAllMocks()
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', () => {
@ -475,6 +640,7 @@ bei Gradidio sei dabei!`,
describe('user is in database and wrong password', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
result = await query({ query: login, variables: { ...variables, password: 'wrong' } })
})
afterAll(async () => {
@ -482,14 +648,16 @@ bei Gradidio sei dabei!`,
})
it('returns an error', () => {
expect(
query({ query: login, variables: { ...variables, password: 'wrong' } }),
).resolves.toEqual(
expect(result).toEqual(
expect.objectContaining({
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', () => {
let user: User[]
const variables = {
email: 'bibi@bloxberg.de',
password: 'Aa12345_',
@ -569,6 +739,7 @@ bei Gradidio sei dabei!`,
beforeAll(async () => {
await query({ query: login, variables })
user = await User.find()
})
afterAll(() => {
@ -595,13 +766,25 @@ 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', () => {
const variables = { email: 'bibi@bloxberg.de' }
const emailCodeRequestTime = CONFIG.EMAIL_CODE_REQUEST_TIME
describe('user is not in DB', () => {
describe('duration not expired', () => {
it('returns true', async () => {
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
expect.objectContaining({
@ -612,24 +795,42 @@ bei Gradidio sei dabei!`,
)
})
})
})
describe('user exists in DB', () => {
let result: any
let loginEmailOptIn: LoginEmailOptIn[]
let emailContact: UserContact
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await resetEntity(LoginEmailOptIn)
result = await mutate({ mutation: forgotPassword, variables })
loginEmailOptIn = await LoginEmailOptIn.find()
// await resetEntity(LoginEmailOptIn)
emailContact = await UserContact.findOneOrFail(variables)
})
afterAll(async () => {
await cleanDB()
CONFIG.EMAIL_CODE_REQUEST_TIME = emailCodeRequestTime
})
describe('duration not expired', () => {
it('returns true', async () => {
await expect(result).toEqual(
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
expect.objectContaining({
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,
@ -637,10 +838,11 @@ bei Gradidio sei dabei!`,
}),
)
})
})
it('sends reset password email', () => {
expect(sendResetPasswordEmail).toBeCalledWith({
link: activationLink(loginEmailOptIn[0]),
link: activationLink(emailContact.emailVerificationCode),
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
@ -650,22 +852,27 @@ bei Gradidio sei dabei!`,
describe('request reset password again', () => {
it('thows an error', async () => {
CONFIG.EMAIL_CODE_REQUEST_TIME = emailCodeRequestTime
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
expect.objectContaining({
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', () => {
let loginEmailOptIn: LoginEmailOptIn[]
let emailContact: UserContact
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
loginEmailOptIn = await LoginEmailOptIn.find()
emailContact = await UserContact.findOneOrFail({ email: bibiBloxberg.email })
})
afterAll(async () => {
@ -680,8 +887,8 @@ bei Gradidio sei dabei!`,
expect.objectContaining({
errors: [
// keep Whitspace in error message!
new GraphQLError(`Could not find any entity of type "LoginEmailOptIn" matching: {
"verificationCode": "not-valid"
new GraphQLError(`Could not find any entity of type "UserContact" matching: {
"emailVerificationCode": "not-valid"
}`),
],
}),
@ -694,7 +901,7 @@ bei Gradidio sei dabei!`,
await expect(
query({
query: queryOptIn,
variables: { optIn: loginEmailOptIn[0].verificationCode.toString() },
variables: { optIn: emailContact.emailVerificationCode.toString() },
}),
).resolves.toEqual(
expect.objectContaining({
@ -766,7 +973,7 @@ bei Gradidio sei dabei!`,
})
describe('language is not valid', () => {
it('thows an error', async () => {
it('throws an error', async () => {
await expect(
mutate({
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', () => {
@ -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', () => {
@ -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', () => {
@ -840,7 +1059,7 @@ bei Gradidio sei dabei!`,
)
})
it('can login wtih new password', async () => {
it('can login with new password', async () => {
await expect(
query({
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(
query({
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 { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context'
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql'
import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm'
import CONFIG from '@/config'
import { User } from '@model/User'
import { User as DbUser } from '@entity/User'
import { UserContact as DbUserContact } from '@entity/UserContact'
import { communityDbUser } from '@/util/communityUser'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { ContributionLink as dbContributionLink } from '@entity/ContributionLink'
@ -16,7 +16,6 @@ import UnsecureLoginArgs from '@arg/UnsecureLoginArgs'
import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
import { OptInType } from '@enum/OptInType'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail'
@ -29,9 +28,12 @@ import {
EventLogin,
EventRedeemRegister,
EventRegister,
EventSendAccountMultiRegistrationEmail,
EventSendConfirmationEmail,
EventActivateAccount,
} from '@/event/Event'
import { getUserCreation } from './util/creations'
import { UserContactType } from '../enum/UserContactType'
import { UserRepository } from '@/typeorm/repository/User'
import { SearchAdminUsersResult } from '@model/AdminUser'
import Paginated from '@arg/Paginated'
@ -146,6 +148,7 @@ const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[
return [encryptionKeyHash, encryptionKey]
}
/*
const getEmailHash = (email: string): Buffer => {
logger.trace('getEmailHash...')
const emailHash = Buffer.alloc(sodium.crypto_generichash_BYTES)
@ -153,6 +156,7 @@ const getEmailHash = (email: string): Buffer => {
logger.debug(`getEmailHash...successful: ${emailHash}`)
return emailHash
}
*/
const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => {
logger.trace('SecretKeyCryptographyEncrypt...')
@ -177,6 +181,19 @@ const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: B
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 => {
logger.trace('newEmailOptIn...')
const emailOptIn = new LoginEmailOptIn()
@ -186,7 +203,8 @@ const newEmailOptIn = (userId: number): LoginEmailOptIn => {
logger.debug(`newEmailOptIn...successful: ${emailOptIn}`)
return emailOptIn
}
*/
/*
// needed by AdminResolver
// checks if given code exists and can be resent
// 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}`)
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 => {
logger.debug(`activationLink(${LoginEmailOptIn})...`)
return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, optInCode.verificationCode.toString())
export const activationLink = (verificationCode: BigInt): string => {
logger.debug(`activationLink(${verificationCode})...`)
return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, verificationCode.toString())
}
const newGradidoID = async (): Promise<string> => {
@ -272,15 +324,12 @@ export class UserResolver {
): Promise<User> {
logger.info(`login with ${email}, ***, ${publisherId} ...`)
email = email.trim().toLowerCase()
const dbUser = await DbUser.findOneOrFail({ email }, { withDeleted: true }).catch(() => {
logger.error(`User with email=${email} does not exists`)
throw new Error('No user with this credentials')
})
const dbUser = await findUserByEmail(email)
if (dbUser.deletedAt) {
logger.error('The User was permanently deleted in database.')
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.')
throw new Error('User email not validated')
}
@ -305,7 +354,7 @@ export class UserResolver {
logger.debug('login credentials valid...')
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
user.hasElopage = await this.hasElopage({ ...context, user: dbUser })
@ -323,7 +372,7 @@ export class UserResolver {
const ev = new EventLogin()
ev.userId = user.id
eventProtocol.writeEvent(new Event().setEventLogin(ev))
logger.info('successful Login:' + user)
logger.info(`successful Login: ${JSON.stringify(user, null, 2)}`)
return user
}
@ -352,19 +401,20 @@ export class UserResolver {
)
// TODO: wrong default value (should be null), how does graphql work here? Is it an required field?
// default int publisher_id = 0;
const event = new Event()
// Validate Language (no throw)
if (!language || !isLanguage(language)) {
language = DEFAULT_LANGUAGE
}
// Validate email unique
// check if user with email still exists?
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
const userFound = await DbUser.findOne({ email }, { withDeleted: true })
logger.info(`DbUser.findOne(email=${email}) = ${userFound}`)
if (await checkEmailExists(email)) {
const foundUser = await findUserByEmail(email)
logger.info(`DbUser.findOne(email=${email}) = ${foundUser}`)
if (userFound) {
if (foundUser) {
// ATTENTION: this logger-message will be exactly expected during tests
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.
@ -385,6 +435,11 @@ export class UserResolver {
lastName,
email,
})
const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail()
eventSendAccountMultiRegistrationEmail.userId = foundUser.id
eventProtocol.writeEvent(
event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail),
)
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
@ -395,23 +450,23 @@ export class UserResolver {
return user
}
}
const passphrase = PassphraseGenerate()
// const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
// const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
// const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
const emailHash = getEmailHash(email)
// const emailHash = getEmailHash(email)
const gradidoID = await newGradidoID()
const eventRegister = new EventRegister()
const eventRedeemRegister = new EventRedeemRegister()
const eventSendConfirmEmail = new EventSendConfirmationEmail()
const dbUser = new DbUser()
let dbUser = new DbUser()
dbUser.gradidoID = gradidoID
dbUser.email = email
dbUser.firstName = firstName
dbUser.lastName = lastName
dbUser.emailHash = emailHash
dbUser.language = language
dbUser.publisherId = publisherId
dbUser.passphrase = passphrase.join(' ')
@ -442,25 +497,38 @@ export class UserResolver {
// loginUser.pubKey = keyPair[0]
// loginUser.privKey = encryptedPrivkey
const event = new Event()
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
await queryRunner.startTransaction('REPEATABLE READ')
try {
await queryRunner.manager.save(dbUser).catch((error) => {
dbUser = await queryRunner.manager.save(dbUser).catch((error) => {
logger.error('Error while saving dbUser', error)
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)
await queryRunner.manager.save(emailOptIn).catch((error) => {
logger.error('Error while saving emailOptIn', error)
throw new Error('error saving email opt in')
})
*/
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g,
emailOptIn.verificationCode.toString(),
emailContact.emailVerificationCode.toString(),
).replace(/{code}/g, redeemCode ? '/' + redeemCode : '')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -475,8 +543,6 @@ export class UserResolver {
eventSendConfirmEmail.userId = dbUser.id
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) {
logger.debug(`Account confirmation link: ${activationLink}`)
}
@ -493,10 +559,10 @@ export class UserResolver {
if (redeemCode) {
eventRedeemRegister.userId = dbUser.id
eventProtocol.writeEvent(event.setEventRedeemRegister(eventRedeemRegister))
await eventProtocol.writeEvent(event.setEventRedeemRegister(eventRedeemRegister))
} else {
eventRegister.userId = dbUser.id
eventProtocol.writeEvent(event.setEventRegister(eventRegister))
await eventProtocol.writeEvent(event.setEventRegister(eventRegister))
}
return new User(dbUser)
@ -507,22 +573,29 @@ export class UserResolver {
async forgotPassword(@Arg('email') email: string): Promise<boolean> {
logger.info(`forgotPassword(${email})...`)
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) {
logger.warn(`no user found with ${email}`)
return true
}
// can be both types: REGISTER and RESET_PASSWORD
let optInCode = await LoginEmailOptIn.findOne({
userId: user.id,
})
// let optInCode = await LoginEmailOptIn.findOne({
// 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)
logger.info(`optInCode for ${email}=${optInCode}`)
// optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD)
logger.info(`optInCode for ${email}=${dbUserContact}`)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendResetPasswordEmailMailer({
link: activationLink(optInCode),
link: activationLink(dbUserContact.emailVerificationCode),
firstName: user.firstName,
lastName: user.lastName,
email,
@ -532,7 +605,7 @@ export class UserResolver {
/* 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(`Reset password link: ${activationLink(optInCode)}`)
logger.debug(`Reset password link: ${activationLink(dbUserContact.emailVerificationCode)}`)
}
logger.info(`forgotPassword(${email}) successful...`)
@ -548,19 +621,29 @@ export class UserResolver {
logger.info(`setPassword(${code}, ***)...`)
// Validate Password
if (!isPassword(password)) {
logger.error('Password entered is lexically invalid')
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!',
)
}
// Load code
/*
const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: code }).catch(() => {
logger.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
if (!isOptInValid(optInCode)) {
if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
logger.error(
`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`,
)
}
logger.debug('optInCode is valid...')
logger.debug('EmailVerificationCode is valid...')
// load user
const user = await DbUser.findOneOrFail({ id: optInCode.userId }).catch(() => {
logger.error('Could not find corresponding Login User')
throw new Error('Could not find corresponding Login User')
})
logger.debug('user with optInCode found...')
const user = userContact.user
logger.debug('user with EmailVerificationCode found...')
// Generate Passphrase if needed
if (!user.passphrase) {
@ -595,10 +675,10 @@ export class UserResolver {
logger.debug('Passphrase is valid...')
// Activate EMail
user.emailChecked = true
userContact.emailChecked = true
// 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 encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
user.password = passwordHash[0].readBigUInt64LE() // using the shorthash
@ -608,7 +688,9 @@ export class UserResolver {
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
await queryRunner.startTransaction('REPEATABLE READ')
const event = new Event()
try {
// Save user
@ -616,12 +698,21 @@ export class UserResolver {
logger.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()
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) {
await queryRunner.rollbackTransaction()
logger.error('Error on writing User data:' + e)
logger.error('Error on writing User and UserContact data:' + e)
throw e
} finally {
await queryRunner.release()
@ -629,11 +720,11 @@ export class UserResolver {
// Sign into Klicktipp
// 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 {
await klicktippSignIn(user.email, user.language, user.firstName, user.lastName)
await klicktippSignIn(userContact.email, user.language, user.firstName, user.lastName)
logger.debug(
`klicktippSignIn(${user.email}, ${user.language}, ${user.firstName}, ${user.lastName})`,
`klicktippSignIn(${userContact.email}, ${user.language}, ${user.firstName}, ${user.lastName})`,
)
} catch (e) {
logger.error('Error subscribe to klicktipp:' + e)
@ -652,10 +743,10 @@ export class UserResolver {
@Query(() => Boolean)
async queryOptIn(@Arg('optIn') optIn: string): Promise<boolean> {
logger.info(`queryOptIn(${optIn})...`)
const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: optIn })
logger.debug(`found optInCode=${optInCode}`)
const userContact = await DbUserContact.findOneOrFail({ emailVerificationCode: optIn })
logger.debug(`found optInCode=${userContact}`)
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isOptInValid(optInCode)) {
if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
logger.error(
`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.
const oldPasswordHash = SecretKeyCryptographyCreateKey(userEntity.email, password)
const oldPasswordHash = SecretKeyCryptographyCreateKey(
userEntity.emailContact.email,
password,
)
if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) {
logger.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])
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...')
const encryptedPrivkey = SecretKeyCryptographyEncrypt(privKey, newPasswordHash[1])
logger.debug('PrivateKey encrypted...')
@ -723,10 +820,11 @@ export class UserResolver {
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
await queryRunner.startTransaction('REPEATABLE READ')
try {
await queryRunner.manager.save(userEntity).catch((error) => {
logger.error('error saving user: ' + error)
throw new Error('error saving user: ' + error)
})
@ -747,12 +845,8 @@ export class UserResolver {
@Query(() => Boolean)
async hasElopage(@Ctx() context: Context): Promise<boolean> {
logger.info(`hasElopage()...`)
const userEntity = context.user
if (!userEntity) {
logger.info('missing context.user for EloPage-check')
return false
}
const elopageBuys = hasElopageBuys(userEntity.email)
const userEntity = getUser(context)
const elopageBuys = hasElopageBuys(userEntity.emailContact.email)
logger.debug(`has ElopageBuys = ${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 timeElapsed = Date.now() - new Date(optIn.updatedAt).getTime()
// time is given in minutes
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 => {
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 => {
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 } => {
if (time > 60) {

View File

@ -15,14 +15,21 @@ export const validateContribution = (
amount: Decimal,
creationDate: Date,
): void => {
logger.trace('isContributionValid', creations, amount, creationDate)
logger.trace('isContributionValid: ', creations, amount, creationDate)
const index = getCreationIndex(creationDate.getMonth())
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')
}
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(
`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()
const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day'
logger.trace('getUserCreations dateFilter', dateFilter)
logger.trace('getUserCreations dateFilter=', dateFilter)
const unionString = includePending
? `
@ -51,6 +58,7 @@ export const getUserCreations = async (
AND contribution_date >= ${dateFilter}
AND confirmed_at IS NULL AND deleted_at IS NULL`
: ''
logger.trace('getUserCreations unionString=', unionString)
const unionQuery = await queryRunner.manager.query(`
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
ORDER BY date DESC
`)
logger.trace('getUserCreations unionQuery=', unionQuery)
await queryRunner.release()
@ -82,6 +91,7 @@ export const getUserCreations = async (
export const getUserCreation = async (id: number, includePending = true): Promise<Decimal[]> => {
logger.trace('getUserCreation', id, includePending)
const creations = await getUserCreations([id], includePending)
logger.trace('getUserCreation creations=', creations)
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', () => {
expect((createTransport as jest.Mock).mock.results[0].value.sendMail).toBeCalledWith({
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
to: 'receiver@mail.org',
to: `${CONFIG.EMAIL_TEST_RECEIVER}`,
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',

View File

@ -19,6 +19,12 @@ export const sendEMail = async (emailDef: {
logger.info(`Emails are disabled via config...`)
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({
host: CONFIG.EMAIL_SMTP_URL,
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',
senderEmail: 'bibi@bloxberg.de',
amount: new Decimal(42.0),
memo: 'Vielen herzlichen Dank für den neuen Hexenbesen!',
overviewURL: 'http://localhost/overview',
})
})
@ -33,7 +32,6 @@ describe('sendTransactionReceivedEmail', () => {
expect.stringContaining('42,00 GDD') &&
expect.stringContaining('Bibi Bloxberg') &&
expect.stringContaining('(bibi@bloxberg.de)') &&
expect.stringContaining('Vielen herzlichen Dank für den neuen Hexenbesen!') &&
expect.stringContaining('http://localhost/overview'),
})
})

View File

@ -11,7 +11,6 @@ export const sendTransactionReceivedEmail = (data: {
email: string
senderEmail: string
amount: Decimal
memo: string
overviewURL: string
}): Promise<boolean> => {
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
senderEmail: string
amount: Decimal
memo: string
overviewURL: string
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName}
@ -19,16 +18,12 @@ export const transactionReceived = {
Du hast soeben ${data.amount.toFixed(2).replace('.', ',')} GDD von ${data.senderFirstName} ${
data.senderLastName
} (${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!
Mit freundlichen Grüßen,
dein Gradido-Team
Link zu deinem Konto: ${data.overviewURL}`,
dein Gradido-Team`,
},
}

View File

@ -11,7 +11,11 @@ export const contributionLinkFactory = async (
const { mutate, query } = client
// 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 = {
amount: contributionLink.amount,

View File

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

View File

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

View File

@ -261,3 +261,31 @@ export const deleteContribution = gql`
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/explicit-module-boundary-types */
import { backendLogger as logger } from '@/server/logger'
import createServer from '../server/createServer'
import { createTestClient } from 'apollo-server-testing'
@ -50,11 +51,14 @@ const run = async () => {
const seedClient = createTestClient(server.apollo)
const { con } = server
await cleanDB()
logger.info('##seed## clean database successful...')
// seed the standard users
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
for (let i = 0; i < 100; i++) {
@ -64,7 +68,9 @@ const run = async () => {
email: internet.email(),
language: datatype.boolean() ? 'en' : 'de',
})
logger.info(`##seed## seed ${i}. random user`)
}
logger.info('##seed## seeding all random users successful...')
// create GDD
for (let i = 0; i < creations.length; i++) {
@ -73,16 +79,19 @@ const run = async () => {
// 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)
}
logger.info('##seed## seeding all creations successful...')
// create Transaction Links
for (let i = 0; i < transactionLinks.length; i++) {
await transactionLinkFactory(seedClient, transactionLinks[i])
}
logger.info('##seed## seeding all transactionLinks successful...')
// create Contribution Links
for (let i = 0; i < contributionLinks.length; i++) {
await contributionLinkFactory(seedClient, contributionLinks[i])
}
logger.info('##seed## seeding all contributionLinks successful...')
await con.close()
}

View File

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

View File

@ -75,6 +75,9 @@ const createServer = async (
logger,
})
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')
return { apollo, app, con }
}

View File

@ -1,28 +1,39 @@
import { Brackets, EntityRepository, ObjectLiteral, Repository } from '@dbTools/typeorm'
import { User } from '@entity/User'
import SearchUsersFilters from '@/graphql/arg/SearchUsersFilters'
import { Brackets, EntityRepository, IsNull, Not, Repository } from '@dbTools/typeorm'
import { User as DbUser } from '@entity/User'
@EntityRepository(User)
export class UserRepository extends Repository<User> {
async findByPubkeyHex(pubkeyHex: string): Promise<User> {
return this.createQueryBuilder('user')
@EntityRepository(DbUser)
export class UserRepository extends Repository<DbUser> {
async findByPubkeyHex(pubkeyHex: string): Promise<DbUser> {
const dbUser = await this.createQueryBuilder('user')
.leftJoinAndSelect('user.emailContact', 'emailContact')
.where('hex(user.pubKey) = :pubkeyHex', { pubkeyHex })
.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(
select: string[],
searchCriteria: string,
filterCriteria: ObjectLiteral[],
filters: SearchUsersFilters,
currentPage: number,
pageSize: number,
): Promise<[User[], number]> {
const query = await this.createQueryBuilder('user')
): Promise<[DbUser[], number]> {
const query = this.createQueryBuilder('user')
.select(select)
.leftJoinAndSelect('user.emailContact', 'emailContact')
.withDeleted()
.where(
new Brackets((qb) => {
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}%`,
lastName: `%${searchCriteria}%`,
@ -31,9 +42,23 @@ export class UserRepository extends Repository<User> {
)
}),
)
/*
filterCriteria.forEach((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
.take(pageSize)
.skip((currentPage - 1) * pageSize)

View File

@ -2,22 +2,26 @@
import { SaveOptions, RemoveOptions } from '@dbTools/typeorm'
import { User as dbUser } from '@entity/User'
import { UserContact } from '@entity/UserContact'
// import { UserContact as EmailContact } from '@entity/UserContact'
import { User } from '@model/User'
const communityDbUser: dbUser = {
id: -1,
gradidoID: '11111111-2222-4333-4444-55555555',
alias: '',
email: 'support@gradido.net',
// email: 'support@gradido.net',
emailContact: new UserContact(),
emailId: -1,
firstName: 'Gradido',
lastName: 'Akademie',
pubKey: Buffer.from(''),
privKey: Buffer.from(''),
deletedAt: null,
password: BigInt(0),
emailHash: Buffer.from(''),
// emailHash: Buffer.from(''),
createdAt: new Date(),
emailChecked: false,
// emailChecked: false,
language: '',
isAdmin: null,
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 { 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> => {
// 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?
if ((await dbUser.count({ email })) !== 0) {
// if ((await dbUser.count({ email })) !== 0) {
if ((await dbUserContact.count({ email })) !== 0) {
// eslint-disable-next-line no-console
console.log(`Did not create User - already exists with email: ${email}`)
return

View File

@ -8,6 +8,7 @@ import {
PrimaryGeneratedColumn,
} from 'typeorm'
import { Contribution } from '../Contribution'
import { User } from '../User'
@Entity('contribution_messages', {
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 })
userId: number
@ManyToOne(() => User, (user) => user.messages)
@JoinColumn({ name: 'user_id' })
user: User
@Column({ length: 2000, nullable: false, collation: 'utf8mb4_unicode_ci' })
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 { TransactionLink } from './TransactionLink'
import { User } from './User'
import { UserContact } from './UserContact'
import { Contribution } from './Contribution'
import { EventProtocol } from './EventProtocol'
import { ContributionMessage } from './ContributionMessage'
@ -20,4 +21,5 @@ export const entities = [
User,
EventProtocol,
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",
"version": "1.11.0",
"version": "1.12.1",
"description": "Gradido Database Tool to execute database migrations",
"main": "src/index.ts",
"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"
# backend
BACKEND_CONFIG_VERSION=v9.2022-07-07
BACKEND_CONFIG_VERSION=v10.2022-09-20
JWT_EXPIRES_IN=10m
GDT_API_URL=https://gdt.gradido.net
ENV_NAME=stage1
TYPEORM_LOGGING_RELATIVE_PATH=../deployment/bare_metal/log/typeorm.backend.log
@ -40,6 +41,8 @@ KLICKTIPP_APIKEY_DE=
KLICKTIPP_APIKEY_EN=
EMAIL=true
EMAIL_TEST_MODUS=false
EMAIL_TEST_RECEIVER=test_team@gradido.net
EMAIL_USERNAME=peter@lustig.de
EMAIL_SENDER=peter@lustig.de
EMAIL_PASSWORD=1234
@ -61,7 +64,7 @@ EVENT_PROTOCOL_DISABLED=false
DATABASE_CONFIG_VERSION=v1.2022-03-18
# frontend
FRONTEND_CONFIG_VERSION=v2.2022-04-07
FRONTEND_CONFIG_VERSION=v3.2022-09-16
GRAPHQL_URI=https://stage1.gradido.net/graphql
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_AUTHOR="Bernd Hückstädt - Gradido-Akademie"
SUPPORT_MAIL=support@supportmail.com
# admin
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)
# 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
SCRIPT_PATH=$(realpath $0)
SCRIPT_DIR=$(dirname $SCRIPT_PATH)
@ -75,7 +81,7 @@ pm2 startup
sudo apt-get install -y certbot
sudo apt-get install -y python3-certbot-nginx
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
> 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
@ -90,7 +96,7 @@ sudo certbot
# Install 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
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
# Install mysql autobackup
@ -131,6 +137,10 @@ envsubst "$(env | sed -e 's/=.*//' -e 's/^/\$/g')" < $PROJECT_ROOT/frontend/.env
# Configure admin
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
# Note: on first startup some errors will occur - nothing serious
./start.sh

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