Merge branch 'master' into 2144-feature-usecase-concept-setting-alias-in-user-profile

This commit is contained in:
clauspeterhuebner 2022-09-30 00:28:57 +02:00 committed by GitHub
commit 979ae3f14d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
121 changed files with 7954 additions and 931 deletions

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

@ -7,7 +7,6 @@
v-model="form.text"
:placeholder="$t('contributionLink.memo')"
rows="3"
max-rows="6"
></b-form-textarea>
<b-row class="mt-4 mb-6">
<b-col>

View File

@ -20,7 +20,7 @@ describe('ContributionMessagesListItem', () => {
const propsData = {
contributionId: 42,
state: 'PENDING0',
state: 'PENDING',
message: {
id: 111,
message: 'asd asda sda sda',

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

@ -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

@ -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",
@ -74,10 +75,20 @@
"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": "+"
@ -133,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",
@ -74,10 +75,20 @@
"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": "+"
@ -133,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

@ -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>

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

@ -10,14 +10,14 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0048-add_is_moderator_to_contribution_messages',
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

@ -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

@ -5,7 +5,7 @@ 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

View File

@ -13,7 +13,7 @@ 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

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

@ -1126,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'),
],
}),
)
})
@ -1516,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,12 +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?
@ -81,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',
]
@ -107,7 +92,7 @@ export class AdminResolver {
return 'user.' + fieldName
}),
searchText,
filterCriteria,
filters,
currentPage,
pageSize,
)
@ -124,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
@ -245,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
@ -272,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])
@ -309,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.')
}
@ -349,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
@ -366,7 +366,7 @@ export class AdminResolver {
const contributions = await getConnection()
.createQueryBuilder()
.select('c')
.from(Contribution, 'c')
.from(DbContribution, 'c')
.leftJoinAndSelect('c.messages', 'm')
.where({ confirmedAt: IsNull() })
.getMany()
@ -377,7 +377,11 @@ export class AdminResolver {
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)
@ -394,8 +398,9 @@ export class AdminResolver {
@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
@ -410,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)
@ -428,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()
@ -468,7 +480,7 @@ 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.')
@ -477,7 +489,7 @@ export class AdminResolver {
senderLastName: moderatorUser.lastName,
recipientFirstName: user.firstName,
recipientLastName: user.lastName,
recipientEmail: user.email,
recipientEmail: user.emailContact.email,
contributionMemo: contribution.memo,
contributionAmount: contribution.amount,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
@ -493,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
}
@ -720,12 +740,15 @@ export class AdminResolver {
@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('READ UNCOMMITTED')
await queryRunner.startTransaction('REPEATABLE READ')
const contributionMessage = DbContributionMessage.create()
try {
const contribution = await Contribution.findOne({
const contribution = await DbContribution.findOne({
where: { id: contributionId },
relations: ['user'],
})
@ -735,6 +758,11 @@ export class AdminResolver {
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
@ -749,21 +777,21 @@ export class AdminResolver {
contribution.contributionStatus === ContributionStatus.PENDING
) {
contribution.contributionStatus = ContributionStatus.IN_PROGRESS
await queryRunner.manager.update(Contribution, { id: contributionId }, contribution)
await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution)
}
await queryRunner.commitTransaction()
await sendAddedContributionMessageEmail({
senderFirstName: user.firstName,
senderLastName: user.lastName,
recipientFirstName: contribution.user.firstName,
recipientLastName: contribution.user.lastName,
recipientEmail: contribution.user.email,
senderEmail: user.email,
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}`)

View File

@ -23,7 +23,7 @@ export class ContributionMessageResolver {
const user = getUser(context)
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
await queryRunner.startTransaction('REPEATABLE READ')
const contributionMessage = DbContributionMessage.create()
try {
const contribution = await Contribution.findOne({ id: contributionId })

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

@ -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

@ -8,10 +8,7 @@ import { Context, getUser } from '@/server/context'
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
import { getCustomRepository, getConnection } from '@dbTools/typeorm'
import {
sendTransactionLinkRedeemedEmail,
sendTransactionReceivedEmail,
} from '@/mailer/sendTransactionReceivedEmail'
import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail'
import { Transaction } from '@model/Transaction'
import { TransactionList } from '@model/TransactionList'
@ -38,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,
@ -81,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
@ -151,10 +150,9 @@ 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,
memo,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
})
if (transactionLink) {
@ -163,8 +161,8 @@ export const executeTransaction = async (
senderLastName: recipient.lastName,
recipientFirstName: sender.firstName,
recipientLastName: sender.lastName,
email: sender.email,
senderEmail: recipient.email,
email: sender.emailContact.email,
senderEmail: recipient.emailContact.email,
amount,
memo,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
@ -187,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(
@ -309,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,15 +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'
@ -84,7 +94,7 @@ describe('UserResolver', () => {
}
let result: any
let emailOptIn: string
let emailVerificationCode: string
let user: User[]
beforeAll(async () => {
@ -103,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', () => {
@ -117,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,
@ -141,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,
})
})
})
})
@ -161,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,
@ -219,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' }),
}),
]),
)
})
})
@ -236,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,
}),
]),
@ -248,13 +264,15 @@ 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
@ -277,17 +295,25 @@ describe('UserResolver', () => {
})
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,
}),
}),
)
})
@ -299,6 +325,99 @@ describe('UserResolver', () => {
}),
)
})
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,
}),
)
})
})
/* A transaction link requires GDD on account
@ -341,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 () => {
@ -362,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')
})
/*
@ -388,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' },
})
})
@ -459,6 +581,7 @@ bei Gradidio sei dabei!`,
describe('no users in database', () => {
beforeAll(async () => {
jest.clearAllMocks()
result = await query({ query: login, variables })
})
@ -471,7 +594,9 @@ bei Gradidio sei dabei!`,
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('User with email=bibi@bloxberg.de does not exist')
expect(logger.error).toBeCalledWith(
'UserContact with email=bibi@bloxberg.de does not exists',
)
})
})
@ -656,7 +781,10 @@ bei Gradidio sei dabei!`,
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({
@ -667,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,
@ -692,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',
@ -704,7 +851,8 @@ bei Gradidio sei dabei!`,
})
describe('request reset password again', () => {
it('throws an error', async () => {
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')],
@ -720,11 +868,11 @@ bei Gradidio sei dabei!`,
})
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 () => {
@ -739,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"
}`),
],
}),
@ -753,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({

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,10 +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'
@ -147,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)
@ -154,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...')
@ -178,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()
@ -187,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
@ -227,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> => {
@ -273,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 exist`)
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')
}
@ -306,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 })
@ -324,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
}
@ -353,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.
@ -386,33 +435,38 @@ 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
if (!emailSent) {
logger.debug(`Email not sent!`)
logger.debug(`Email not send!`)
}
logger.info('createUser() faked and send multi registration mail...')
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(' ')
@ -443,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
@ -476,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}`)
}
@ -494,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)
@ -508,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,
@ -533,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...`)
@ -556,13 +628,22 @@ export class UserResolver {
}
// 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`,
)
@ -570,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) {
@ -597,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
@ -610,7 +688,7 @@ export class UserResolver {
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
await queryRunner.startTransaction('REPEATABLE READ')
const event = new Event()
@ -620,17 +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 and UserContact data written successfully...')
const eventActivateAccount = new EventActivateAccount()
eventActivateAccount.userId = user.id
eventProtocol.writeEvent(event.setEventActivateAccount(eventActivateAccount))
logger.info('User data written successfully...')
} 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()
@ -638,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)
@ -661,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`,
)
@ -712,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`)
@ -720,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...')
@ -732,7 +820,7 @@ 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) => {
@ -757,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
}
@ -798,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

@ -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

@ -1,7 +1,7 @@
import { backendLogger as logger } from '@/server/logger'
import Decimal from 'decimal.js-light'
import { sendEMail } from './sendEMail'
import { transactionLinkRedeemed, transactionReceived } from './text/transactionReceived'
import { transactionReceived } from './text/transactionReceived'
export const sendTransactionReceivedEmail = (data: {
senderFirstName: string
@ -11,7 +11,6 @@ export const sendTransactionReceivedEmail = (data: {
email: string
senderEmail: string
amount: Decimal
memo: string
overviewURL: string
}): Promise<boolean> => {
logger.info(
@ -26,27 +25,3 @@ export const sendTransactionReceivedEmail = (data: {
text: transactionReceived.de.text(data),
})
}
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

@ -14,9 +14,10 @@ export const contributionConfirmed = {
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName},
Dein Gradido Schöpfungsantrag "${data.contributionMemo}" wurde soeben von ${data.senderFirstName} ${
data.senderLastName
} bestätigt.
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!

View File

@ -14,17 +14,15 @@ export const contributionMessageReceived = {
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName},
Du hast soeben zu deinem eingereichten Gradido Schöpfungsantrag "${data.contributionMemo}" eine Rückfrage von ${data.senderFirstName} ${data.senderLastName} erhalten.
Die Rückfrage lautet:
du hast soeben zu deinem eingereichten Gemeinwohl-Beitrag "${data.contributionMemo}" eine Rückfrage von ${data.senderFirstName} ${data.senderLastName} erhalten.
${data.message}
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
Link zu deinem Konto: ${data.overviewURL}`,
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,47 +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}`,
},
}
export const transactionLinkRedeemed = {
de: {
subject: 'Gradido link eingelösst',
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össt.
Betrag: ${data.amount.toFixed(2).replace('.', ',')} GDD,
Memo: ${data.memo}
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

@ -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

@ -7,16 +7,16 @@ export async function retrieveNotRegisteredEmails(): Promise<string[]> {
if (!con) {
throw new Error('No connection to database')
}
const users = await User.find()
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.email)
await getKlickTippUser(user.emailContact.email)
} catch (err) {
notRegisteredUser.push(user.email)
notRegisteredUser.push(user.emailContact.email)
// eslint-disable-next-line no-console
console.log(`${user.email}`)
console.log(`${user.emailContact.email}`)
}
}
await con.close()

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

@ -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 { User } from './0047-messages_tables/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,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

@ -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

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)
@ -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

View File

@ -1,99 +1,233 @@
# Setup script to setup the server be ready to run gradido
# This assums you have root access via ssh to your cleanly setup server
# Furthermore this assumes you have debian (11 64bit) running
# Check your (Sub-)Domain with your Provider.
# In this document gddhost.tld refers to your chosen domain
# Instructions To Run `Gradido` On Your Server
> ssh root@gddhost.tld
We split setting up `Gradido` on your server into three steps:
# change root default shell
> chsh -s /bin/bash
# Create user `gradido`
> useradd -d /home/gradido -m gradido
> passwd gradido
>> enter new password twice
- [Preparing your server](#command-list-to-setup-your-server-be-ready-to-install-gradido)
- [Installing `Gradido`](#use-commands-in-installsh-manually-in-your-shell-for-now)
- [Crone-Job for `Gradido`](#define-cronjob-to-compensate-yarn-output-in-tmp)
# Gives the user priviledges - this might be omitted in order to harden security
# Care: This will require another administering user if you don't want root access.
# Since this setup expects the user running the software be the same as the administering user,
# you have to adjust the instructions according to that scenario.
# You might lock yourself out, if done wrong.
> usermod -a -G sudo gradido
## Command List To Setup Your Server Be Ready To Install `Gradido`
# change gradido default shell
> chsh -s /bin/bash gradido
# Install sudo
> apt-get install sudo
# switch to the new user
> su gradido
We assume you have root access via ssh to your cleanly setup server.
Furthermore we assume you have debian (11 64bit) running.
# Register first ssh key for user `gradido`
> mkdir ~/.ssh
> chmod 700 ~/.ssh
> nano ~/.ssh/authorized_keys
>> insert public key
>> ctrl + x
>> save
Check your (Sub-)Domain with your Provider.
In this document `gddhost.tld` refers to your chosen domain.
# Test authentication via SSH
> ssh -i /path/to/privKey gradido@gddhost.tld
>> This should log you in and allow you to use sudo commands, which will require the user's password
### SSH into your server
# Disable password authentication & root login
> cd /etc/ssh
> sudo cp sshd_config sshd_config.org
> sudo nano sshd_config
>> change `PermitRootLogin yes` to `PermitRootLogin no`
>> change `#PasswordAuthentication yes` to `PasswordAuthentication no`
>> change `UsePAM yes` to `UsePAM no`
>> ctrl + x
>> save
> sudo /etc/init.d/ssh restart
```bash
ssh root@gddhost.tld
```
# Test SSH Access only, no root ssh access
> ssh gradido@gddhost.tld
>> Will result in in either a password request for your key or the message `Permission denied (publickey)`
> ssh -i /path/to/privKey root@gddhost.tld
>> Will result in `Permission denied (publickey)`
> ssh -i /path/to/privKey gradido@gddhost.tld
>> Will succeed after entering the correct keys password (if any)
### Change root default shell
# update system
> sudo apt-get update
> sudo apt-get upgrade
```bash
chsh -s /bin/bash
```
# Install security tools
## ufw
> sudo apt-get install ufw
> sudo ufw allow http
> sudo ufw allow https
> sudo ufw allow ssh
> sudo ufw enable
### Create user `gradido`
## fail2ban
> sudo apt-get install -y fail2ban
> sudo /etc/init.d/fail2ban restart
```bash
$ useradd -d /home/gradido -m gradido
$ passwd gradido
# enter new password twice
```
# Install gradido
> sudo apt-get install -y git
> cd ~
> git clone https://github.com/gradido/gradido.git
### Give the user priviledges
# Timezone
# Note: This is needed - since there is Summer-Time included in the default server Setup - UTC is REQUIRED for production data
> sudo timedatectl set-timezone UTC
# > sudo timedatectl set-ntp on
# > sudo apt purge ntp
# > sudo systemctl start systemd-timesyncd
# >> timedatectl to verify
This might be omitted in order to harden security.
# Adjust .env
# NOTE ';' can not be part of any value
# The Github Secret is Created on Github in Settimgs -> Webhooks
> cd gradido/deployment/bare_metal
> cp .env.dist .env
> nano .env
>> Adjust values accordingly
# TODO the install.sh is not yet ready to run directly - consider to use it as pattern to do it manually
> ./install.sh
***!!! Attention !!!***
- Care: This will require another administering user if you don't want root access.
- Since this setup expects the user running the software be the same as the administering user,
- you have to adjust the instructions according to that scenario.
- you might lock yourself out, if done wrong.
#### Add the new user `gradido` to `sudo` group
```bash
usermod -a -G sudo gradido
```
### Change gradido default shell
```bash
chsh -s /bin/bash gradido
```
### Install sudo
```bash
apt-get install sudo
```
### Switch to the new user
```bash
su gradido
```
### Register first ssh key for user `gradido`
```bash
$ mkdir ~/.ssh
$ chmod 700 ~/.ssh
$ nano ~/.ssh/authorized_keys
# insert public key
# ctrl + x
# save
```
### Test authentication via SSH
If you logout from the server you can test authentication:
```bash
$ ssh -i /path/to/privKey gradido@gddhost.tld
# This should log you in and allow you to use sudo commands, which will require the user's password
```
### Disable password authentication and root login
```bash
$ cd /etc/ssh
$ sudo cp sshd_config sshd_config.org
$ sudo nano sshd_config
# change 'PermitRootLogin yes' to `PermitRootLogin no`
# change 'PasswordAuthentication yes' to 'PasswordAuthentication no'
# change 'UsePAM yes' to 'UsePAM no'
# ctrl + x
# save
$ sudo /etc/init.d/ssh restart
```
### Test SSH Access only, no root ssh access
```bash
$ ssh gradido@gddhost.tld
# Will result in in either a passphrase request for your key or the message 'Permission denied (publickey)'
$ ssh -i /path/to/privKey root@gddhost.tld
# Will result in 'Permission denied (publickey)'
$ ssh -i /path/to/privKey gradido@gddhost.tld
# Will succeed after entering the correct keys passphrase (if any)
```
### Update system
```bash
sudo apt-get update
sudo apt-get upgrade
```
### Install security tools
#### Install: `ufw`
```bash
sudo apt-get install ufw
sudo ufw allow http
sudo ufw allow https
sudo ufw allow ssh
sudo ufw enable
```
#### Install: `fail2ban`
```bash
sudo apt-get install -y fail2ban
sudo /etc/init.d/fail2ban restart
```
### Install `Gradido` code
```bash
sudo apt-get install -y git
cd ~
git clone https://github.com/gradido/gradido.git
```
### Timezone
*Note: This is needed - since there is Summer-Time included in the default server Setup - UTC is REQUIRED for production data.*
```bash
sudo timedatectl set-timezone UTC
sudo timedatectl set-ntp on
sudo apt purge ntp
sudo systemctl start systemd-timesyncd
# timedatectl to verify
```
### Adjust the values in `.env`
***!!! Attention !!!***
*Don't forget this step!
All your following installations in `install.sh` will fail!*
*Notes:*
- *`;` cannot be part of any value!*
- *The GitHub secret is created on GitHub in Settings -> Webhooks.*
#### Create `.env` and set values
```bash
$ cd gradido/deployment/bare_metal
$ cp .env.dist .env
$ nano .env
# adjust values accordingly
```
## Use Commands In `install.sh` Manually In Your Shell For Now
The script `install.sh` is not yet ready to run directly.
Use it as pattern to do all steps manually in your terminal shell.
*TODO: Bring the `install.sh` script to run in the shell.*
***!!! Attention !!!***
- *Commands in `install.sh`:*
- *The commands for setting the paths in the used env variables are not working directly in the terminal, consider the out commented commands for this purpose.*
Follow the commands in `./install.sh` as installation pattern.
## Define Cronjob To Compensate Yarn Output In `/tmp`
`yarn` creates output in `/tmp` directory, which must be deleted regularly and will be done per Cron-Job.
### On `stage1`
An hourly job is necessary on `stage1` by setting the following job in the `crontab` for the `gradido` user.
Run:
```bash
crontab -e
```
This opens the crontab in edit-mode and insert the following entry:
```bash
0 * * * * find /tmp -name "yarn--*" -cmin +60 -exec rm -r {} \; > /dev/null
```
### On `stage2`
A daily job is necessary on `stage2` by setting the following job in the `crontab` for the `gradido` user.
Run:
```bash
crontab -e
```
This opens the `crontab` in edit-mode and insert the following entry:
```bash
0 4 * * * find /tmp -name "yarn--*" -ctime +1 -exec rm -r {} \; > /dev/null
```

View File

@ -59,3 +59,4 @@ networks:
volumes:
db_test_vol:

View File

@ -6,30 +6,41 @@ With the business event protocol the gradido application will capture and persis
The different event types will be defined as Enum. The following list is a first draft and will grow with further event types in the future.
| EventType | Value | Description |
| ----------------------------------- | ----- | ------------------------------------------------------------------------------------------------------ |
| BasicEvent | 0 | the basic event is the root of all further extending event types |
| VisitGradidoEvent | 10 | if a user visits a gradido page without login or register |
| RegisterEvent | 20 | the user presses the register button |
| RedeemRegisterEvent | 21 | the user presses the register button initiated by the redeem link |
| InActiveAccountEvent | 22 | the systems create an inactive account during the register process |
| SendConfirmEmailEvent | 23 | the system send a confirmation email to the user during the register process |
| ConfirmEmailEvent | 24 | the user confirms his email during the register process |
| RegisterEmailKlickTippEvent | 25 | the system registers the confirmed email at klicktipp |
| LoginEvent | 30 | the user presses the login button |
| RedeemLoginEvent | 31 | the user presses the login button initiated by the redeem link |
| ActivateAccountEvent | 32 | the system activates the users account during the first login process |
| PasswordChangeEvent | 33 | the user changes his password |
| TransactionSendEvent | 40 | the user creates a transaction and sends it online |
| TransactionSendRedeemEvent | 41 | the user creates a transaction and sends it per redeem link |
| TransactionRepeateRedeemEvent | 42 | the user recreates a redeem link of a still open transaction |
| TransactionCreationEvent | 50 | the user receives a creation transaction for his confirmed contribution |
| TransactionReceiveEvent | 51 | the user receives a transaction from an other user and posts the amount on his account |
| TransactionReceiveRedeemEvent | 52 | the user activates the redeem link and receives the transaction and posts the amount on his account |
| ContributionCreateEvent | 60 | the user enters his contribution and asks for confirmation |
| ContributionConfirmEvent | 61 | the user confirms a contribution of an other user (for future multi confirmation from several users) |
| ContributionLinkDefineEvent | 70 | the admin user defines a contributionLink, which could be send per Link/QR-Code on an other medium |
| ContributionLinkActivateRedeemEvent | 71 | the user activates a received contributionLink to create a contribution entry for the contributionLink |
| EventType | Description |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| BasicEvent | the basic event is the root of all further extending event types |
| VisitGradidoEvent | if a user visits a gradido page without login or register; possible as soon as a request-response-loop for the first page will be invoked |
| RegisterEvent | the user presses the register button |
| LoginEvent | the user presses the login button |
| VerifyRedeemEvent | the user presses a redeem link independent from transaction or contribution redeem |
| RedeemRegisterEvent | the user presses the register-button initiated by the redeem link |
| RedeemLoginEvent | the user presses the login-button initiated by the redeem link |
| ActivateAccountEvent | the system activates the users account after a successful confirmEmail-Event or during a reactivation of a deactivated account |
| InActiveAccountEvent | the systems creates an inactive account during the register process or an active account will be reset to inactive |
| SetPasswordEvent | the system sets a new password after ConfirmEmailEvent or SendForgotPasswordEvent |
| RegisterEmailKlickTippEvent | the system registers the confirmed email at klicktipp |
| PasswordChangeEvent | the user changes his password in his Profile |
| TransactionSendEvent | the user creates a transaction and sends it online; paired with TransactionReceiveEvent |
| TransactionLinkCreateEvent | the user creates a transaction link |
| TransactionReceiveEvent | the user receives a transaction from an other user and posts the amount on his account; paired with TransactionSendEvent |
| TransactionLinkRedeemEvent | the user activates the redeem link and receives the transaction and posts the amount on his account |
| ContributionCreateEvent | the user enters his contribution and asks for confirmation |
| ContributionConfirmEvent | the admin user confirms a contribution of an other user (for future multi confirmation from several users) |
| ContributionDenyEvent | the admin user denies a contribution of an other user |
| ContributionLinkDefineEvent | the admin user defines a contributionLink, which could be send per Link/QR-Code on an other medium |
| ContributionLinkRedeemEvent | the user activates a received contributionLink to create a contribution entry for the contributionLink |
| UserCreateContributionMessageEvent | the user captures a new message for a contribution |
| AdminCreateContributionMessageEvent | the admin user captures a new message for a contribution |
| LogoutEvent | the user invokes a logout |
| SendConfirmEmailEvent | the system sends a confirmation email to the user during the registration process |
| SendAccountMultiRegistrationEmailEvent | the system sends a info email to the user, that an other user tries to register with his existing email address |
| SendForgotPasswordEmailEvent | the system sends the forgot password email including a special link to start the forgot password process |
| SendTransactionSendEmailEvent | the system sends an email to inform the user about his transaction was sent to an other user |
| SendTransactionReceiveEmailEvent | the system sends an email to inform the user about a received transaction from an other user |
| SendAddedContributionEmailEvent | the system sends an email to inform the user about the creation of his captured contribution |
| SendContributionConfirmEmailEvent | the system sends an email to inform the user about the confirmation of his contribution |
| SendTransactionLinkRedeemEmailEvent | the system sends an email to the user, who created the transactionlink, that the link was redeemed |
| | |
## EventProtocol - Entity
@ -49,32 +60,44 @@ The business events will be stored in database in the new table `EventProtocol`.
## Event Types
The following table lists for each event type the mandatory attributes, which have to be initialized at event occurence and to be written in the database event protocol table:
The following table lists for each event type the mapping between old and new key, the mandatory attributes, which have to be initialized at event occurence and to be written in the database event protocol table:
| EventType | id | type | createdAt | userID | XuserID | XCommunityID | transactionID | contribID | amount |
| :---------------------------------- | :-: | :--: | :-------: | :----: | :-----: | :----------: | :-----------: | :-------: | :----: |
| BasicEvent | x | x | x | | | | | | |
| VisitGradidoEvent | x | x | x | | | | | | |
| RegisterEvent | x | x | x | x | | | | | |
| RedeemRegisterEvent | x | x | x | x | | | (x) | (x) | |
| InActiveAccountEvent | x | x | x | x | | | | | |
| SendConfirmEmailEvent | x | x | x | x | | | | | |
| ConfirmEmailEvent | x | x | x | x | | | | | |
| RegisterEmailKlickTippEvent | x | x | x | x | | | | | |
| LoginEvent | x | x | x | x | | | | | |
| RedeemLoginEvent | x | x | x | x | | | (x) | (x) | |
| ActivateAccountEvent | x | x | x | x | | | | | |
| PasswordChangeEvent | x | x | x | x | | | | | |
| TransactionSendEvent | x | x | x | x | x | x | x | | x |
| TransactionSendRedeemEvent | x | x | x | x | x | x | x | | x |
| TransactionRepeateRedeemEvent | x | x | x | x | x | x | x | | x |
| TransactionCreationEvent | x | x | x | x | | | x | | x |
| TransactionReceiveEvent | x | x | x | x | x | x | x | | x |
| TransactionReceiveRedeemEvent | x | x | x | x | x | x | x | | x |
| ContributionCreateEvent | x | x | x | x | | | | x | x |
| ContributionConfirmEvent | x | x | x | x | x | x | | x | x |
| ContributionLinkDefineEvent | x | x | x | x | | | | | x |
| ContributionLinkActivateRedeemEvent | x | x | x | x | | | | x | x |
| EventType - old key | EventType - new key | id | type | createdAt | userID | XuserID | XCommunityID | transactionID | contribID | amount |
| :-------------------------------- | :------------------------------------- | :-: | :--: | :-------: | :----: | :-----: | :----------: | :-----------: | :-------: | :----: |
| BASIC | BasicEvent | x | x | x | | | | | | |
| VISIT_GRADIDO | VisitGradidoEvent | x | x | x | | | | | | |
| REGISTER | RegisterEvent | x | x | x | x | | | | | |
| LOGIN | LoginEvent | x | x | x | x | | | | | |
| | VerifyRedeemEvent | | | | | | | | | |
| REDEEM_REGISTER | RedeemRegisterEvent | x | x | x | x | | | (x) | (x) | |
| REDEEM_LOGIN | RedeemLoginEvent | x | x | x | x | | | (x) | (x) | |
| ACTIVATE_ACCOUNT | ActivateAccountEvent | x | x | x | x | | | | | |
| INACTIVE_ACCOUNT | InActiveAccountEvent | x | x | x | x | | | | | |
| CONFIRM_EMAIL | SetPasswordEvent | x | x | x | x | | | | | |
| REGISTER_EMAIL_KLICKTIPP | RegisterEmailKlickTippEvent | x | x | x | x | | | | | |
| PASSWORD_CHANGE | PasswordChangeEvent | x | x | x | x | | | | | |
| TRANSACTION_SEND | TransactionSendEvent | x | x | x | x | x | x | x | | x |
| TRANSACTION_CREATION | TransactionLinkCreateEvent | x | x | x | x | | | x | | x |
| TRANSACTION_RECEIVE | TransactionReceiveEvent | x | x | x | x | x | x | x | | x |
| TRANSACTION_SEND_REDEEM | TransactionLinkRedeemEvent | x | x | x | x | x | x | x | | x |
| CONTRIBUTION_CREATE | ContributionCreateEvent | x | x | x | x | | | | x | x |
| CONTRIBUTION_CONFIRM | ContributionConfirmEvent | x | x | x | x | x | x | | x | x |
| | ContributionDenyEvent | x | x | x | x | x | x | | x | x |
| CONTRIBUTION_LINK_DEFINE | ContributionLinkDefineEvent | x | x | x | x | | | | | x |
| CONTRIBUTION_LINK_ACTIVATE_REDEEM | ContributionLinkRedeemEvent | x | x | x | x | | | | x | x |
| | UserCreateContributionMessageEvent | x | x | x | x | | | | x | x |
| | AdminCreateContributionMessageEvent | x | x | x | x | | | | x | x |
| | LogoutEvent | x | x | x | x | | | | x | x |
| SEND_CONFIRMATION_EMAIL | SendConfirmEmailEvent | x | x | x | x | | | | | |
| | SendAccountMultiRegistrationEmailEvent | x | x | x | x | | | | | |
| | SendForgotPasswordEmailEvent | x | x | x | x | | | | | |
| | SendTransactionSendEmailEvent | x | x | x | x | x | x | x | | x |
| | SendTransactionReceiveEmailEvent | x | x | x | x | x | x | x | | x |
| | SendAddedContributionEmailEvent | x | x | x | x | | | | x | x |
| | SendContributionConfirmEmailEvent | x | x | x | x | | | | x | x |
| | SendTransactionLinkRedeemEmailEvent | x | x | x | x | x | x | x | | x |
| TRANSACTION_REPEATE_REDEEM | - | | | | | | | | | |
| TRANSACTION_RECEIVE_REDEEM | - | | | | | | | | | |
## Event creation

View File

@ -2,7 +2,7 @@
## Motivation
To introduce the Gradido-ID base on the requirement to identify an user account per technical key instead of using an email-address. Such a technical key ensures an exact identification of an user account without giving detailed information for possible missusage.
The introduction of the Gradido-ID base on the requirement to identify an user account per technical key instead of using an email-address. Such a technical key ensures an exact identification of an user account without giving detailed information for possible missusage.
Additionally the Gradido-ID allows to administrade any user account data like changing the email address or define several email addresses without any side effects on the identification of the user account.
@ -23,11 +23,11 @@ The second step is to decribe all concerning business logic processes, which hav
The entity users has to be changed by adding the following columns.
| Column | Type | Description |
| ------------------------ | ------ | -------------------------------------------------------------------------------------- |
| ------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------- |
| gradidoID | String | technical unique key of the user as UUID (version 4) |
| alias | String | a business unique key of the user |
| passphraseEncryptionType | int | defines the type of encrypting the passphrase: 1 = email (default), 2 = gradidoID, ... |
| emailID | int | technical foreign key to the new entity Contact |
| emailID | int | technical foreign key to the entry with type Email and contactChannel=maincontact of the new entity UserContacts |
##### Email vs emailID
@ -40,11 +40,18 @@ The preferred and proper solution will be to add a new column `Users.emailId `as
A new entity `UserContacts `is introduced to store several contacts of different types like email, telephone or other kinds of contact addresses.
| Column | Type | Description |
| --------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| --------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| id | int | the technical key of a contact entity |
| type | int | Defines the type of contact entry as enum: Email, Phone, etc |
| usersID | int | Defines the foreign key to the `Users` table |
| userID | int | Defines the foreign key to the `Users` table |
| email | String | defines the address of a contact entry of type Email |
| emailVerificationCode | unsinged bigint(20) | unique code to verify email or password reset |
| emailOptInType | int | REGISTER=1, RESET_PASSWORD=2 |
| emailResendCount | int | counter how often the email was resend |
| emailChecked | boolean | flag if email is verified and confirmed |
| createdAt | DateTime | point of time the Contact was created |
| updatedAt | DateTime | point of time the Contact was updated |
| deletedAt | DateTime | point of time the Contact was soft deleted |
| phone | String | defines the address of a contact entry of type Phone |
| contactChannels | String | define the contact channel as comma separated list for which this entry is confirmed by the user e.g. main contact (default), infomail, contracting, advertisings, ... |
@ -58,18 +65,24 @@ In a one-time migration create for each entry of the `Users `tabel an unique UUI
#### Primary Email Contact
In a one-time migration read for each entry of the `Users `table the `Users.id` and `Users.email` and create for it a new entry in the `UsersContact `table, by initializing the contact-values with:
In a one-time migration read for each entry of the `Users `table the `Users.id` and `Users.email`, select from the table `login_email_opt_in` the entry with the `login_email_opt_in.user_id` = `Users.id` and create a new entry in the `UsersContact `table, by initializing the contact-values with:
* id = new technical key
* type = Enum-Email
* userID = `Users.id`
* email = `Users.email`
* emailVerifyCode = `login_email_opt_in.verification_code`
* emailOptInType = `login_email_opt_in.email_opt_in_type_id`
* emailResendCount = `login_email_opt_in.resent_count`
* emailChecked = `Users.emailChecked`
* createdAt = `login_email_opt_in.created_at`
* updatedAt = `login_email_opt_in.updated_at`
* phone = null
* usedChannel = Enum-"main contact"
and update the `Users `entry with `Users.emailId = UsersContact.Id` and `Users.passphraseEncryptionType = 1`
After this one-time migration the column `Users.email` can be deleted.
After this one-time migration and a verification, which ensures that all data are migrated, then the columns `Users.email`, `Users.emailChecked`, `Users.emailHash` and the table `login_email_opt_in` can be deleted.
### Adaption of BusinessLogic
@ -109,7 +122,7 @@ The logic of change password has to be adapted by
* read the users email address from the `UsersContact `table
* give the email address as input for the password decryption of the existing password
* use the `Users.userID` as input for the password encryption fo the new password
* use the `Users.userID` as input for the password encryption for the new password
* change the `Users.passphraseEnrycptionType` to the new value =2
* if the `Users.passphraseEncryptionType` = 2, then
@ -129,11 +142,17 @@ A new logic has to be introduced to search the user identity per different input
A new mapping logic will be necessary to allow using unmigrated APIs like GDT-servers api. So it must be possible to give this identity-mapping logic the following input to get the respective output:
* email -> userID
* email -> gradidoID
* email -> alias
* userID -> gradidoID
* userID -> email
* userID -> alias
* alias -> gradidoID
* alias -> email
* alias -> userID
* gradidoID -> email
* gradidoID -> userID
* gradidoID -> alias
#### GDT-Access

7
e2e-tests/README.md Normal file
View File

@ -0,0 +1,7 @@
# Gradido end-to-end tests
This is still WIP.
For automated end-to-end testing one of the frameworks Cypress or Playwright will be utilized.
For more details on how to run them, see the subfolders' README instructions.

4
e2e-tests/cypress/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
tests/node_modules/
tests/cypress/screenshots/
tests/cypress/videos/
tests/cucumber-messages.ndjson

View File

@ -0,0 +1,36 @@
###############################################################################
# Dockerfile to create a ready-to-use Cypress Docker image for end-to-end
# testing.
#
# Based on the images containing several browsers, provided by Cypress.io
# (https://github.com/cypress-io/cypress-docker-images/tree/master/browsers)
# this Dockerfile is based a slim Linux Dockerfile using Node.js 16.14.2.
#
# Here the latest stable versions of the browsers Chromium and Firefox are
# installed before installing Cypress.
###############################################################################
FROM cypress/base:16.14.2-slim
ARG DOCKER_WORKDIR=/tests/
WORKDIR $DOCKER_WORKDIR
# install dependencies
RUN apt-get -qq update > /dev/null && \
apt-get -qq install -y bzip2 mplayer wget > /dev/null
# install Chromium browser
RUN apt-get -qq install -y chromium > /dev/null
# install Firefox browser
RUN wget --no-verbose -O /tmp/firefox.tar.bz2 "https://download.mozilla.org/?product=firefox-latest&os=linux64&lang=en-US" && \
tar -C /opt -xjf /tmp/firefox.tar.bz2 && \
rm /tmp/firefox.tar.bz2 && \
ln -fs /opt/firefox/firefox /usr/bin/firefox
# clean up
RUN rm -rf /var/lib/apt/lists/* && apt-get -qq clean > /dev/null
COPY tests/package.json tests/yarn.lock $DOCKER_WORKDIR
RUN yarn install
COPY tests/ $DOCKER_WORKDIR

View File

@ -0,0 +1,73 @@
# Gradido End-to-End Testing with [Cypress](https://www.cypress.io/) (CI-ready via Docker)
A setup to show-case Cypress as an end-to-end testing tool for Gradido running in a Docker container.
The tests are organized in feature files written in Gherkin syntax.
## Features under test
So far these features are initially tested
- [User authentication](https://github.com/gradido/gradido/blob/master/e2e-tests/cypress/tests/cypress/e2e/User.Authentication.feature)
- [User profile - change password](https://github.com/gradido/gradido/blob/master/e2e-tests/cypress/tests/cypress/e2e/UserProfile.ChangePassword.feature)
- [User registration]((https://github.com/gradido/gradido/blob/master/e2e-tests/cypress/tests/cypress/e2e/User.Registration.feature)) (WIP)
## Precondition
Before running the tests, change to the repo's root directory (gradido).
### Boot up the system under test
```bash
docker-compose up
```
### Seed the database
The database has to be seeded upfront to every test run.
```bash
# change to the backend directory
cd /path/to/gradido/gradido/backend
# install all dependencies
yarn
# seed the database (everytime before running the tests)
yarn seed
```
## Execute the test
This setup will be integrated in the Gradido Github Actions to automatically support the CI/CD process.
For now the test setup can only be used locally in two modes.
### Run Cypress directly from the code
```bash
# change to the tests directory
cd /path/to/gradido/e2e-tests/cypress/tests
# install all dependencies
yarn install
# a) run the tests on command line
yarn cypress run
# b) open the Cypress GUI to run the tests in interactive mode
yarn cypress open
```
### Run Cyprss from a separate Docker container
```bash
# change to the cypress directory
cd /path/to/gradido/e2e-tests/cypress/
# build a Docker image from the Dockerfile
docker build -t gradido_e2e-tests-cypress .
# run the Docker image and execute the given tests
docker run -it --network=host gradido_e2e-tests-cypress yarn cypress-e2e
```

View File

@ -0,0 +1 @@
node_modules

View File

@ -0,0 +1,24 @@
module.exports = {
root: true,
env: {
node: true,
},
parser: "@typescript-eslint/parser",
plugins: ["cypress", "prettier", "@typescript-eslint"],
extends: [
"standard",
"eslint:recommended",
"plugin:prettier/recommended",
"plugin:@typescript-eslint/recommended",
],
rules: {
"no-console": ["error"],
"no-debugger": "error",
"prettier/prettier": [
"error",
{
htmlWhitespaceSensitivity: "ignore",
},
],
},
};

View File

@ -0,0 +1,65 @@
import { defineConfig } from "cypress";
import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor";
import browserify from "@badeball/cypress-cucumber-preprocessor/browserify";
async function setupNodeEvents(
on: Cypress.PluginEvents,
config: Cypress.PluginConfigOptions
): Promise<Cypress.PluginConfigOptions> {
await addCucumberPreprocessorPlugin(on, config);
on(
"file:preprocessor",
browserify(config, {
typescript: require.resolve("typescript"),
})
);
on("after:run", (results) => {
if (results) {
// results will be undefined in interactive mode
// eslint-disable-next-line no-console
console.log(results.status);
}
});
return config;
}
export default defineConfig({
e2e: {
specPattern: "**/*.feature",
excludeSpecPattern: "*.js",
baseUrl: "http://localhost:3000",
chromeWebSecurity: false,
defaultCommandTimeout: 10000,
supportFile: "cypress/support/index.ts",
viewportHeight: 720,
viewportWidth: 1280,
retries: {
runMode: 2,
openMode: 0,
},
env: {
backendURL: "http://localhost:4000",
loginQuery: `query ($email: String!, $password: String!, $publisherId: Int) {
login(email: $email, password: $password, publisherId: $publisherId) {
email
firstName
lastName
language
klickTipp {
newsletterState
__typename
}
hasElopage
publisherId
isAdmin
creation
__typename
}
}`,
},
setupNodeEvents,
},
});

View File

@ -0,0 +1,17 @@
Feature: User authentication
As a user
I want to be able to sign in - only with valid credentials
In order to be able to posts and do other contributions as myself
Furthermore I want to be able to stay logged in and logout again
# TODO for these pre-conditions utilize seeding or API check, if user exists in test system
# Background:
# Given the following "users" are in the database:
# | email | password | name |
# | bibi@bloxberg.de | Aa12345_ | Bibi Bloxberg |
Scenario: Log in successfully
Given the browser navigates to page "/login"
When the user submits the credentials "bibi@bloxberg.de" "Aa12345_"
Then the user is logged in with username "Bibi Bloxberg"

View File

@ -0,0 +1,13 @@
Feature: User registration
As a user
I want to register to create an account
@skip
Scenario: Register successfully
Given the browser navigates to page "/register"
When the user fills name and email "Regina" "Register" "regina@register.com"
And the user agrees to the privacy policy
And the user submits the registration form
Then the user can use a provided activation link
And the user can set a password "Aa12345_"
And the user can login with the credentials "regina@register.com" "Aa12345_"

View File

@ -0,0 +1,27 @@
Feature: User profile - change password
As a user
I want the option to change my password on my profile page.
Background:
# TODO for these pre-conditions utilize seeding or API check, if user exists in test system
# Given the following "users" are in the database:
# | email | password | name |
# | bibi@bloxberg.de | Aa12345_ | Bibi Bloxberg | |
# TODO instead of credentials use the name of an user object (see seeds in backend)
Given the user is logged in as "bibi@bloxberg.de" "Aa12345_"
Scenario: Change password successfully
Given the browser navigates to page "/profile"
And the user opens the change password menu
When the user fills the password form with:
| Old password | Aa12345_ |
| New password | 12345Aa_ |
| Repeat new password | 12345Aa_ |
And the user submits the password form
And the user is presented a "success" message
And the user logs out
Then the user submits the credentials "bibi@bloxberg.de" "Aa12345_"
And the user cannot login
But the user submits the credentials "bibi@bloxberg.de" "12345Aa_"
And the user is logged in with username "Bibi Bloxberg"

View File

@ -0,0 +1,30 @@
/// <reference types="cypress" />
export class LoginPage {
// selectors
emailInput = "input[type=email]";
passwordInput = "input[type=password]";
submitBtn = "[type=submit]";
emailHint = "#vee_Email";
passwordHint = "#vee_Password";
goto() {
cy.visit("/");
return this;
}
enterEmail(email: string) {
cy.get(this.emailInput).clear().type(email);
return this;
}
enterPassword(password: string) {
cy.get(this.passwordInput).clear().type(password);
return this;
}
submitLogin() {
cy.get(this.submitBtn).click();
return this;
}
}

View File

@ -0,0 +1,10 @@
/// <reference types="cypress" />
export class OverviewPage {
navbarName = '[data-test="navbar-item-username"]';
goto() {
cy.visit("/overview");
return this;
}
}

View File

@ -0,0 +1,35 @@
/// <reference types="cypress" />
export class ProfilePage {
// selectors
openChangePassword = "[data-test=open-password-change-form]";
oldPasswordInput = "#password-input-field";
newPasswordInput = "#new-password-input-field";
newPasswordRepeatInput = "#repeat-new-password-input-field";
submitNewPasswordBtn = "[data-test=submit-new-password-btn]";
goto() {
cy.visit("/profile");
return this;
}
enterOldPassword(password: string) {
cy.get(this.oldPasswordInput).clear().type(password);
return this;
}
enterNewPassword(password: string) {
cy.get(this.newPasswordInput).find("input").clear().type(password);
return this;
}
enterRepeatPassword(password: string) {
cy.get(this.newPasswordRepeatInput).find("input").clear().type(password);
return this;
}
submitPasswordForm() {
cy.get(this.submitNewPasswordBtn).click();
return this;
}
}

View File

@ -0,0 +1,42 @@
/// <reference types="cypress" />
export class RegistrationPage {
// selectors
firstnameInput = "#registerFirstname";
lastnameInput = "#registerLastname";
emailInput = "#Email-input-field";
checkbox = "#registerCheckbox";
submitBtn = "[type=submit]";
RegistrationThanxHeadline = ".test-message-headline";
RegistrationThanxText = ".test-message-subtitle";
goto() {
cy.visit("/register");
return this;
}
enterFirstname(firstname: string) {
cy.get(this.firstnameInput).clear().type(firstname);
return this;
}
enterLastname(lastname: string) {
cy.get(this.lastnameInput).clear().type(lastname);
return this;
}
enterEmail(email: string) {
cy.get(this.emailInput).clear().type(email);
return this;
}
checkPrivacyCheckbox() {
cy.get(this.checkbox).click({ force: true });
}
submitRegistrationPage() {
cy.get(this.submitBtn).should("be.enabled");
cy.get(this.submitBtn).click();
}
}

View File

@ -0,0 +1,17 @@
/// <reference types="cypress" />
export class SideNavMenu {
// selectors
profileMenu = "[data-test=profile-menu]";
logoutMenu = "[data-test=logout-menu]";
openUserProfile() {
cy.get(this.profileMenu).click();
return this;
}
logout() {
cy.get(this.logoutMenu).click();
return this;
}
}

View File

@ -0,0 +1,10 @@
/// <reference types="cypress" />
export class Toasts {
// selectors
toastSlot = ".b-toaster-slot";
toastTypeSuccess = ".b-toast-success";
toastTypeError = ".b-toast-danger";
toastTitle = ".gdd-toaster-title";
toastMessage = ".gdd-toaster-body";
}

View File

@ -0,0 +1,7 @@
{
"user": {
"email": "bibi@bloxberg.de",
"password": "Aa12345_",
"name": "Bibi Bloxberg"
}
}

View File

@ -0,0 +1,38 @@
import jwtDecode from "jwt-decode";
Cypress.Commands.add("login", (email, password) => {
cy.clearLocalStorage("vuex");
cy.request({
method: "POST",
url: Cypress.env("backendURL"),
body: {
operationName: null,
variables: {
email: email,
password: password,
},
query: Cypress.env("loginQuery"),
},
}).then(async (response) => {
const token = response.headers.token;
let tokenTime;
// to avoid JWT InvalidTokenError, the decoding of the token is wrapped
// in a try-catch block (see
// https://github.com/auth0/jwt-decode/issues/65#issuecomment-395493807)
try {
tokenTime = jwtDecode(token).exp;
} catch (tokenDecodingError) {
cy.log("JWT decoding error: ", tokenDecodingError);
}
const vuexToken = {
token: token,
tokenTime: tokenTime,
};
cy.visit("/");
window.localStorage.setItem("vuex", JSON.stringify(vuexToken));
});
});

View File

@ -0,0 +1,14 @@
/* eslint-disable @typescript-eslint/no-namespace */
/* eslint-disable @typescript-eslint/no-explicit-any */
/// <reference types="cypress" />
import "./e2e";
declare global {
namespace Cypress {
interface Chainable<Subject> {
login(email: string, password: string): Chainable<any>;
}
}
}

View File

@ -0,0 +1,52 @@
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
import { LoginPage } from "../../e2e/models/LoginPage";
import { OverviewPage } from "../../e2e/models/OverviewPage";
import { SideNavMenu } from "../../e2e/models/SideNavMenu";
import { Toasts } from "../../e2e/models/Toasts";
Given("the browser navigates to page {string}", (page: string) => {
cy.visit(page);
});
// login-related
Given(
"the user is logged in as {string} {string}",
(email: string, password: string) => {
cy.login(email, password);
}
);
Then("the user is logged in with username {string}", (username: string) => {
const overviewPage = new OverviewPage();
cy.url().should("include", "/overview");
cy.get(overviewPage.navbarName).should("contain", username);
});
Then("the user cannot login", () => {
const toast = new Toasts();
cy.get(toast.toastSlot).within(() => {
cy.get(toast.toastTypeError);
cy.get(toast.toastTitle).should("be.visible");
cy.get(toast.toastMessage).should("be.visible");
});
});
//
When(
"the user submits the credentials {string} {string}",
(email: string, password: string) => {
const loginPage = new LoginPage();
loginPage.enterEmail(email);
loginPage.enterPassword(password);
loginPage.submitLogin();
}
);
// logout
Then("the user logs out", () => {
const sideNavMenu = new SideNavMenu();
sideNavMenu.logout();
});

View File

@ -0,0 +1,7 @@
import { When } from "@badeball/cypress-cucumber-preprocessor";
import { LoginPage } from "../../e2e/models/LoginPage";
When("the user submits no credentials", () => {
const loginPage = new LoginPage();
loginPage.submitLogin();
});

View File

@ -0,0 +1,32 @@
import { And, When } from "@badeball/cypress-cucumber-preprocessor";
import { ProfilePage } from "../../e2e/models/ProfilePage";
import { Toasts } from "../../e2e/models/Toasts";
const profilePage = new ProfilePage();
And("the user opens the change password menu", () => {
cy.get(profilePage.openChangePassword).click();
cy.get(profilePage.newPasswordRepeatInput).should("be.visible");
cy.get(profilePage.submitNewPasswordBtn).should("be.disabled");
});
When("the user fills the password form with:", (table) => {
table = table.rowsHash();
profilePage.enterOldPassword(table["Old password"]);
profilePage.enterNewPassword(table["New password"]);
profilePage.enterRepeatPassword(table["Repeat new password"]);
cy.get(profilePage.submitNewPasswordBtn).should("be.enabled");
});
And("the user submits the password form", () => {
profilePage.submitPasswordForm();
});
When("the user is presented a {string} message", (type: string) => {
const toast = new Toasts();
cy.get(toast.toastSlot).within(() => {
cy.get(toast.toastTypeSuccess);
cy.get(toast.toastTitle).should("be.visible");
cy.get(toast.toastMessage).should("be.visible");
});
});

View File

@ -0,0 +1,24 @@
import { And, When } from "@badeball/cypress-cucumber-preprocessor";
import { RegistrationPage } from "../../e2e/models/RegistrationPage";
const registrationPage = new RegistrationPage();
When(
"the user fills name and email {string} {string} {string}",
(firstname: string, lastname: string, email: string) => {
const registrationPage = new RegistrationPage();
registrationPage.enterFirstname(firstname);
registrationPage.enterLastname(lastname);
registrationPage.enterEmail(email);
}
);
And("the user agrees to the privacy policy", () => {
registrationPage.checkPrivacyCheckbox();
});
And("the user submits the registration form", () => {
registrationPage.submitRegistrationPage();
cy.get(registrationPage.RegistrationThanxHeadline).should("be.visible");
cy.get(registrationPage.RegistrationThanxText).should("be.visible");
});

View File

@ -0,0 +1,39 @@
{
"name": "gradido-e2e-tests-cypress",
"version": "1.0.0",
"description": "End-to-end tests with Cypress",
"main": "yarn run cypress run",
"repository": "https://github.com/gradido/gradido/e2e-tests/cypress",
"author": "Mathias Lenz",
"license": "Apache-2.0",
"private": false,
"cypress-cucumber-preprocessor": {
"nonGlobalStepDefinitions": true,
"json": {
"enabled": true
}
},
"scripts": {
"cypress-e2e": "cypress run",
"lint": "eslint --max-warnings=0 --ext .js,.ts ."
},
"dependencies": {
"@badeball/cypress-cucumber-preprocessor": "^12.0.0",
"@cypress/browserify-preprocessor": "^3.0.2",
"@typescript-eslint/eslint-plugin": "^5.38.0",
"@typescript-eslint/parser": "^5.38.0",
"cypress": "^10.4.0",
"eslint": "^8.23.1",
"eslint-config-prettier": "^8.3.0",
"eslint-config-standard": "^16.0.3",
"eslint-loader": "^4.0.2",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "^5.1.0",
"jwt-decode": "^3.1.2",
"prettier": "^2.7.1",
"typescript": "^4.7.4"
}
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "es2016",
"lib": ["es6", "dom"],
"baseUrl": "../node_modules",
"types": ["cypress", "node"],
"strict": true
},
"include": ["**/*.ts"]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
###############################################################################
# Dockerfile to create a ready-to-use Playwright Docker image for end-to-end
# testing.
#
# To avoid hardcoded versoning of Playwright, this Dockerfile is a custom
# version of the ready-to-use Dockerfile privided by Playwright developement
# (https://github.com/microsoft/playwright/blob/main/utils/docker/Dockerfile.focal)
#
# Here the latest stable versions of the browsers Chromium, Firefox, and Webkit
# (Safari) are installed, icluding all dependencies based on Ubuntu specified by
# Playwright developement.
###############################################################################
FROM ubuntu:focal
# set a timezone for the Playwright browser dependency installation
ARG TZ=Europe/Berlin
ARG DOCKER_WORKDIR=/tests/
WORKDIR $DOCKER_WORKDIR
# package manager preparation
RUN apt-get -qq update && apt-get install -qq -y curl gpg > /dev/null
# for Node.js
RUN curl -sL https://deb.nodesource.com/setup_16.x | bash -
# for Yarn
RUN curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
# install node v16 and Yarn
RUN apt-get -qq update && apt-get install -qq -y nodejs yarn
COPY tests/package.json tests/yarn.lock $DOCKER_WORKDIR
# install Playwright with all dependencies
# for the browsers chromium, firefox, and webkit
RUN yarn install && yarn playwright install --with-deps
# clean up
RUN rm -rf /var/lib/apt/lists/* && apt-get -qq clean
COPY tests/ $DOCKER_WORKDIR

View File

@ -0,0 +1,24 @@
# Gradido End-to-End Testing with [Playwright](https://playwright.dev/) (CI-ready via Docker)
A sample setup to show-case Playwright (using Typescript) as an end-to-end testing tool for Gradido runniing in a Docker container.
Here we have a simple UI-based happy path login test running against the DEV system.
## Precondition
Since dependencies and configurations for Github Actions integration is not set up yet, please run in root directory
```bash
docker-compose up
```
to boot up the DEV system, before running the test.
## Execute the test
```bash
# build a Docker image from the Dockerfile
docker build -t gradido_e2e-tests-playwright .
# run the Docker container and execute the given tests
docker run -it --network=host gradido_e2e-tests-playwright yarn playwright-e2e-tests
```

View File

@ -0,0 +1,8 @@
import { FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
process.env.EMAIL = 'bibi@bloxberg.de';
process.env.PASSWORD = 'Aa12345_';
}
export default globalSetup;

View File

@ -0,0 +1,15 @@
import { test, expect } from '@playwright/test';
import { LoginPage } from './models/login_page';
import { WelcomePage } from './models/welcome_page';
test('Gradido login test (happy path)', async ({ page }) => {
const { EMAIL, PASSWORD } = process.env;
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.enterEmail(EMAIL);
await loginPage.enterPassword(PASSWORD);
await loginPage.submitLogin();
// assertions
await expect(page).toHaveURL('./overview');
});

View File

@ -0,0 +1,33 @@
import { expect, test, Locator, Page } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly url: string;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitBtn: Locator;
constructor(page: Page) {
this.page = page;
this.url = './login';
this.emailInput = page.locator('id=Email-input-field');
this.passwordInput = page.locator('id=Password-input-field');
this.submitBtn = page.locator('text=Login');
}
async goto() {
await this.page.goto(this.url);
}
async enterEmail(email: string) {
await this.emailInput.fill(email);
}
async enterPassword(password: string) {
await this.passwordInput.fill(password);
}
async submitLogin() {
await this.submitBtn.click();
}
}

View File

@ -0,0 +1,13 @@
import { expect, Locator, Page } from '@playwright/test';
export class WelcomePage {
readonly page: Page;
readonly url: string;
readonly profileLink: Locator;
constructor(page: Page){
this.page = page;
this.url = './overview';
this.profileLink = page.locator('href=/profile');
}
}

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