diff --git a/backend/package.json b/backend/package.json index dce2b5e6b..6889c5591 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,7 @@ "lint": "eslint --max-warnings=0 .", "test": "cross-env TZ=UTC NODE_ENV=development jest --runInBand --forceExit --detectOpenHandles", "seed": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/index.ts", - "klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/klicktipp.ts", + "klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/executeKlicktipp.ts", "locales": "scripts/sort.sh" }, "dependencies": { diff --git a/backend/src/apis/KlicktippController.ts b/backend/src/apis/KlicktippController.ts index a2a8f86cb..ea6a8e47c 100644 --- a/backend/src/apis/KlicktippController.ts +++ b/backend/src/apis/KlicktippController.ts @@ -4,8 +4,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ - import { CONFIG } from '@/config' +import { backendLogger as logger } from '@/server/logger' // eslint-disable-next-line import/no-relative-parent-imports import KlicktippConnector from 'klicktipp-api' @@ -41,9 +41,12 @@ export const getKlickTippUser = async (email: string): Promise => { if (!CONFIG.KLICKTIPP) return true const isLogin = await loginKlicktippUser() if (isLogin) { - const subscriberId = await klicktippConnector.subscriberSearch(email) - const result = await klicktippConnector.subscriberGet(subscriberId) - return result + try { + return klicktippConnector.subscriberGet(await klicktippConnector.subscriberSearch(email)) + } catch (e) { + logger.error('Could not find subscriber', email) + return false + } } return false } @@ -62,8 +65,18 @@ export const addFieldsToSubscriber = async ( if (!CONFIG.KLICKTIPP) return true const isLogin = await loginKlicktippUser() if (isLogin) { - const subscriberId = await klicktippConnector.subscriberSearch(email) - return klicktippConnector.subscriberUpdate(subscriberId, fields, newemail, newsmsnumber) + try { + logger.info('Updating of subscriber', email) + return klicktippConnector.subscriberUpdate( + await klicktippConnector.subscriberSearch(email), + fields, + newemail, + newsmsnumber, + ) + } catch (e) { + logger.error('Could not update subscriber', email, fields, e) + return false + } } return false } diff --git a/backend/src/graphql/resolver/util/eventList.ts b/backend/src/graphql/resolver/util/eventList.ts new file mode 100644 index 000000000..45afe1832 --- /dev/null +++ b/backend/src/graphql/resolver/util/eventList.ts @@ -0,0 +1,17 @@ +import { Event as DbEvent } from '@entity/Event' +import { User } from '@entity/User' +import { UserContact } from '@entity/UserContact' + +export const lastDateTimeEvents = async ( + eventType: string, +): Promise<{ email: string; value: Date }[]> => { + return DbEvent.createQueryBuilder('event') + .select('MAX(event.created_at)', 'value') + .leftJoin(User, 'user', 'affected_user_id = user.id') + .leftJoin(UserContact, 'usercontact', 'user.id = usercontact.user_id') + .addSelect('usercontact.email', 'email') + .where('event.type = :eventType', { eventType }) + .andWhere('usercontact.email IS NOT NULL') + .groupBy('event.affected_user_id') + .getRawMany() +} diff --git a/backend/src/server/createServer.ts b/backend/src/server/createServer.ts index d813c541e..c162d9f6f 100644 --- a/backend/src/server/createServer.ts +++ b/backend/src/server/createServer.ts @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable @typescript-eslint/unbound-method */ -import { Connection } from '@dbTools/typeorm' +import { Connection as DbConnection } from '@dbTools/typeorm' import { ApolloServer } from 'apollo-server-express' import express, { Express, json, urlencoded } from 'express' import { Logger } from 'log4js' import { CONFIG } from '@/config' import { schema } from '@/graphql/schema' -import { connection } from '@/typeorm/connection' +import { Connection } from '@/typeorm/connection' import { checkDBVersion } from '@/typeorm/DBVersion' import { elopageWebhook } from '@/webhook/elopage' @@ -24,7 +24,7 @@ import { plugins } from './plugins' interface ServerDef { apollo: ApolloServer app: Express - con: Connection + con: DbConnection } export const createServer = async ( @@ -37,7 +37,7 @@ export const createServer = async ( logger.debug('createServer...') // open mysql connection - const con = await connection() + const con = await Connection.getInstance() if (!con?.isConnected) { logger.fatal(`Couldn't open connection to database!`) throw new Error(`Fatal: Couldn't open connection to database`) diff --git a/backend/src/typeorm/connection.ts b/backend/src/typeorm/connection.ts index 7dec820b5..3c8307478 100644 --- a/backend/src/typeorm/connection.ts +++ b/backend/src/typeorm/connection.ts @@ -1,33 +1,55 @@ // TODO This is super weird - since the entities are defined in another project they have their own globals. // We cannot use our connection here, but must use the external typeorm installation -import { Connection, createConnection, FileLogger } from '@dbTools/typeorm' +import { Connection as DbConnection, createConnection, FileLogger } from '@dbTools/typeorm' import { entities } from '@entity/index' import { CONFIG } from '@/config' -export const connection = async (): Promise => { - try { - return createConnection({ - name: 'default', - type: 'mysql', - host: CONFIG.DB_HOST, - port: CONFIG.DB_PORT, - username: CONFIG.DB_USER, - password: CONFIG.DB_PASSWORD, - database: CONFIG.DB_DATABASE, - entities, - synchronize: false, - logging: true, - logger: new FileLogger('all', { - logPath: CONFIG.TYPEORM_LOGGING_RELATIVE_PATH, - }), - extra: { - charset: 'utf8mb4_unicode_ci', - }, - }) - } catch (error) { - // eslint-disable-next-line no-console - console.log(error) - return null +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class Connection { + private static instance: DbConnection + + /** + * The Singleton's constructor should always be private to prevent direct + * construction calls with the `new` operator. + */ + // eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function + private constructor() {} + + /** + * The static method that controls the access to the singleton instance. + * + * This implementation let you subclass the Singleton class while keeping + * just one instance of each subclass around. + */ + public static async getInstance(): Promise { + if (Connection.instance) { + return Connection.instance + } + try { + Connection.instance = await createConnection({ + name: 'default', + type: 'mysql', + host: CONFIG.DB_HOST, + port: CONFIG.DB_PORT, + username: CONFIG.DB_USER, + password: CONFIG.DB_PASSWORD, + database: CONFIG.DB_DATABASE, + entities, + synchronize: false, + logging: true, + logger: new FileLogger('all', { + logPath: CONFIG.TYPEORM_LOGGING_RELATIVE_PATH, + }), + extra: { + charset: 'utf8mb4_unicode_ci', + }, + }) + return Connection.instance + } catch (error) { + // eslint-disable-next-line no-console + console.log(error) + return null + } } } diff --git a/backend/src/util/executeKlicktipp.ts b/backend/src/util/executeKlicktipp.ts new file mode 100644 index 000000000..74b453307 --- /dev/null +++ b/backend/src/util/executeKlicktipp.ts @@ -0,0 +1,16 @@ +import { Connection } from '@/typeorm/connection' + +import { exportEventDataToKlickTipp } from './klicktipp' + +async function executeKlicktipp(): Promise { + const connection = await Connection.getInstance() + if (connection) { + await exportEventDataToKlickTipp() + await connection.close() + return true + } else { + return false + } +} + +void executeKlicktipp() diff --git a/backend/src/util/klicktipp.test.ts b/backend/src/util/klicktipp.test.ts new file mode 100644 index 000000000..6639a0aa4 --- /dev/null +++ b/backend/src/util/klicktipp.test.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Connection } from '@dbTools/typeorm' +import { Event as DbEvent } from '@entity/Event' +import { ApolloServerTestClient } from 'apollo-server-testing' + +import { testEnvironment, cleanDB, resetToken } from '@test/helpers' + +import { addFieldsToSubscriber } from '@/apis/KlicktippController' +import { creations } from '@/seeds/creation' +import { creationFactory } from '@/seeds/factory/creation' +import { userFactory } from '@/seeds/factory/user' +import { login } from '@/seeds/graphql/mutations' +import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +import { peterLustig } from '@/seeds/users/peter-lustig' + +import { exportEventDataToKlickTipp } from './klicktipp' + +jest.mock('@/apis/KlicktippController') + +let mutate: ApolloServerTestClient['mutate'], con: Connection +let testEnv: { + mutate: ApolloServerTestClient['mutate'] + query: ApolloServerTestClient['query'] + con: Connection +} + +beforeAll(async () => { + testEnv = await testEnvironment() + mutate = testEnv.mutate + con = testEnv.con + await DbEvent.clear() +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +describe('klicktipp', () => { + beforeAll(async () => { + await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, peterLustig) + const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de') + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await creationFactory(testEnv, bibisCreation!) + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(() => { + resetToken() + }) + + describe('exportEventDataToKlickTipp', () => { + it('calls the KlicktippController', async () => { + await exportEventDataToKlickTipp() + expect(addFieldsToSubscriber).toBeCalled() + }) + }) +}) diff --git a/backend/src/util/klicktipp.ts b/backend/src/util/klicktipp.ts index a0ba3c0f7..c07b3128a 100644 --- a/backend/src/util/klicktipp.ts +++ b/backend/src/util/klicktipp.ts @@ -1,14 +1,11 @@ +// eslint-disable @typescript-eslint/no-explicit-any import { User } from '@entity/User' -import { getKlickTippUser } from '@/apis/KlicktippController' -import { LogError } from '@/server/LogError' -import { connection } from '@/typeorm/connection' +import { getKlickTippUser, addFieldsToSubscriber } from '@/apis/KlicktippController' +import { EventType } from '@/event/EventType' +import { lastDateTimeEvents } from '@/graphql/resolver/util/eventList' export async function retrieveNotRegisteredEmails(): Promise { - const con = await connection() - if (!con) { - throw new LogError('No connection to database') - } const users = await User.find({ relations: ['emailContact'] }) const notRegisteredUser = [] for (const user of users) { @@ -20,10 +17,39 @@ export async function retrieveNotRegisteredEmails(): Promise { console.log(`${user.emailContact.email}`) } } - await con.close() // eslint-disable-next-line no-console console.log('User die nicht bei KlickTipp vorhanden sind: ', notRegisteredUser) return notRegisteredUser } -void retrieveNotRegisteredEmails() +async function klickTippSendFieldToUser( + events: { email: string; value: Date }[], + field: string, +): Promise { + for (const event of events) { + const time = event.value.setSeconds(0) + await addFieldsToSubscriber(event.email, { [field]: Math.trunc(time / 1000) }) + } +} + +export async function exportEventDataToKlickTipp(): Promise { + const lastLoginEvents = await lastDateTimeEvents(EventType.USER_LOGIN) + await klickTippSendFieldToUser(lastLoginEvents, 'field186060') + + const registeredEvents = await lastDateTimeEvents(EventType.USER_ACTIVATE_ACCOUNT) + await klickTippSendFieldToUser(registeredEvents, 'field186061') + + const receiveTransactionEvents = await lastDateTimeEvents(EventType.TRANSACTION_RECEIVE) + await klickTippSendFieldToUser(receiveTransactionEvents, 'field185674') + + const contributionCreateEvents = await lastDateTimeEvents(EventType.TRANSACTION_SEND) + await klickTippSendFieldToUser(contributionCreateEvents, 'field185673') + + const linkRedeemedEvents = await lastDateTimeEvents(EventType.TRANSACTION_LINK_REDEEM) + await klickTippSendFieldToUser(linkRedeemedEvents, 'field185676') + + const confirmContributionEvents = await lastDateTimeEvents(EventType.ADMIN_CONTRIBUTION_CONFIRM) + await klickTippSendFieldToUser(confirmContributionEvents, 'field185675') + + return true +} diff --git a/frontend/package.json b/frontend/package.json index 7d89fba7c..fb9b4885f 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,6 +50,7 @@ "prettier": "^2.2.1", "qrcanvas-vue": "2.1.1", "regenerator-runtime": "^0.13.7", + "uuid": "^9.0.0", "vee-validate": "^3.4.5", "vue": "2.6.12", "vue-apollo": "^3.0.7", diff --git a/frontend/src/components/GddSend/TransactionForm.spec.js b/frontend/src/components/GddSend/TransactionForm.spec.js index 41f69960e..e4cee20be 100644 --- a/frontend/src/components/GddSend/TransactionForm.spec.js +++ b/frontend/src/components/GddSend/TransactionForm.spec.js @@ -71,9 +71,9 @@ describe('TransactionForm', () => { }) describe('with balance <= 0.00 GDD the form is disabled', () => { - it('has a disabled input field of type email', () => { + it('has a disabled input field of type text', () => { expect( - wrapper.find('div[data-test="input-email"]').find('input').attributes('disabled'), + wrapper.find('div[data-test="input-identifier"]').find('input').attributes('disabled'), ).toBe('disabled') }) @@ -116,51 +116,54 @@ describe('TransactionForm', () => { expect(wrapper.vm.radioSelected).toBe(SEND_TYPES.send) }) - describe('email field', () => { - it('has an input field of type email', () => { + describe('identifier field', () => { + it('has an input field of type text', () => { expect( - wrapper.find('div[data-test="input-email"]').find('input').attributes('type'), - ).toBe('email') + wrapper.find('div[data-test="input-identifier"]').find('input').attributes('type'), + ).toBe('text') }) - it('has a label form.receiver', () => { - expect(wrapper.find('div[data-test="input-email"]').find('label').text()).toBe( + it('has a label form.recipient', () => { + expect(wrapper.find('div[data-test="input-identifier"]').find('label').text()).toBe( 'form.recipient', ) }) - it('has a placeholder "E-Mail"', () => { + it('has a placeholder for identifier', () => { expect( - wrapper.find('div[data-test="input-email"]').find('input').attributes('placeholder'), - ).toBe('form.email') + wrapper + .find('div[data-test="input-identifier"]') + .find('input') + .attributes('placeholder'), + ).toBe('form.identifier') }) - it('flushes an error message when no valid email is given', async () => { - await wrapper.find('div[data-test="input-email"]').find('input').setValue('a') + it('flushes an error message when no valid identifier is given', async () => { + await wrapper.find('div[data-test="input-identifier"]').find('input').setValue('a') await flushPromises() expect( - wrapper.find('div[data-test="input-email"]').find('.invalid-feedback').text(), - ).toBe('validations.messages.email') + wrapper.find('div[data-test="input-identifier"]').find('.invalid-feedback').text(), + ).toBe('form.validation.valid-identifier') }) // TODO:SKIPPED there is no check that the email being sent to is the same as the user's email. it.skip('flushes an error message when email is the email of logged in user', async () => { await wrapper - .find('div[data-test="input-email"]') + .find('div[data-test="input-identifier"]') .find('input') .setValue('user@example.org') await flushPromises() expect( - wrapper.find('div[data-test="input-email"]').find('.invalid-feedback').text(), + wrapper.find('div[data-test="input-identifier"]').find('.invalid-feedback').text(), ).toBe('form.validation.is-not') }) - it('trims the email after blur', async () => { + it('trims the identifier after blur', async () => { await wrapper - .find('div[data-test="input-email"]') + .find('div[data-test="input-identifier"]') .find('input') .setValue(' valid@email.com ') - await wrapper.find('div[data-test="input-email"]').find('input').trigger('blur') + await wrapper.find('div[data-test="input-identifier"]').find('input').trigger('blur') await flushPromises() expect(wrapper.vm.form.identifier).toBe('valid@email.com') }) @@ -304,7 +307,7 @@ Die ganze Welt bezwingen.“`) it('clears all fields on click', async () => { await wrapper - .find('div[data-test="input-email"]') + .find('div[data-test="input-identifier"]') .find('input') .setValue('someone@watches.tv') await wrapper.find('div[data-test="input-amount"]').find('input').setValue('87.23') @@ -327,7 +330,7 @@ Die ganze Welt bezwingen.“`) describe('submit', () => { beforeEach(async () => { await wrapper - .find('div[data-test="input-email"]') + .find('div[data-test="input-identifier"]') .find('input') .setValue('someone@watches.tv') await wrapper.find('div[data-test="input-amount"]').find('input').setValue('87.23') @@ -380,8 +383,8 @@ Die ganze Welt bezwingen.“`) }) describe('query for username with success', () => { - it('has no email input field', () => { - expect(wrapper.find('div[data-test="input-email"]').exists()).toBe(false) + it('has no identifier input field', () => { + expect(wrapper.find('div[data-test="input-identifier"]').exists()).toBe(false) }) it('queries the username', () => { diff --git a/frontend/src/components/GddSend/TransactionForm.vue b/frontend/src/components/GddSend/TransactionForm.vue index ab56703b2..d5b67d547 100644 --- a/frontend/src/components/GddSend/TransactionForm.vue +++ b/frontend/src/components/GddSend/TransactionForm.vue @@ -59,10 +59,10 @@
- diff --git a/frontend/src/components/Menu/Navbar.vue b/frontend/src/components/Menu/Navbar.vue index 73470a91b..648c273d0 100644 --- a/frontend/src/components/Menu/Navbar.vue +++ b/frontend/src/components/Menu/Navbar.vue @@ -29,10 +29,7 @@
{{ username.username }}
- -
- {{ $store.state.email }} -
+
{{ $store.state.email }}
diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index a29069104..136a07bbf 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -142,6 +142,7 @@ "from": "Von", "generate_now": "Jetzt generieren", "hours": "Stunden", + "identifier": "Email, Nutzername oder Gradido ID", "lastname": "Nachname", "memo": "Nachricht", "message": "Nachricht", @@ -175,7 +176,8 @@ "is-not": "Du kannst dir selbst keine Gradidos überweisen", "username-allowed-chars": "Der Nutzername darf nur aus Buchstaben (ohne Umlaute), Zahlen, Binde- oder Unterstrichen bestehen.", "username-hyphens": "Binde- oder Unterstriche müssen zwischen Buchstaben oder Zahlen stehen.", - "username-unique": "Der Nutzername ist bereits vergeben." + "username-unique": "Der Nutzername ist bereits vergeben.", + "valid-identifier": "Muss eine Email, ein Nutzernamen oder eine gradido ID sein." }, "your_amount": "Dein Betrag" }, diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index ad7077e69..f895145ae 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -142,6 +142,7 @@ "from": "from", "generate_now": "Generate now", "hours": "Hours", + "identifier": "Email, username or gradido ID", "lastname": "Lastname", "memo": "Message", "message": "Message", @@ -175,7 +176,8 @@ "is-not": "You cannot send Gradidos to yourself", "username-allowed-chars": "The username may only contain letters, numbers, hyphens or underscores.", "username-hyphens": "Hyphens or underscores must be in between letters or numbers.", - "username-unique": "This username is already taken." + "username-unique": "This username is already taken.", + "valid-identifier": "Must be a valid email, username or gradido ID." }, "your_amount": "Your amount" }, diff --git a/frontend/src/pages/Login.spec.js b/frontend/src/pages/Login.spec.js index 14bf77aa6..511685efa 100644 --- a/frontend/src/pages/Login.spec.js +++ b/frontend/src/pages/Login.spec.js @@ -146,6 +146,10 @@ describe('Login', () => { expect(mockStoreDispach).toBeCalledWith('login', 'token') }) + it('commits email to store', () => { + expect(mockStoreCommit).toBeCalledWith('email', 'user@example.org') + }) + it('hides the spinner', () => { expect(spinnerHideMock).toBeCalled() }) diff --git a/frontend/src/pages/Login.vue b/frontend/src/pages/Login.vue index c02ee0e45..6fd435c2d 100644 --- a/frontend/src/pages/Login.vue +++ b/frontend/src/pages/Login.vue @@ -100,6 +100,7 @@ export default { data: { login }, } = result this.$store.dispatch('login', login) + this.$store.commit('email', this.form.email) await loader.hide() if (this.$route.params.code) { this.$router.push(`/redeem/${this.$route.params.code}`) diff --git a/frontend/src/pages/Send.spec.js b/frontend/src/pages/Send.spec.js index 3aae5a83e..1001d0c58 100644 --- a/frontend/src/pages/Send.spec.js +++ b/frontend/src/pages/Send.spec.js @@ -66,8 +66,11 @@ describe('Send', () => { beforeEach(async () => { const transactionForm = wrapper.findComponent({ name: 'TransactionForm' }) await transactionForm.findAll('input[type="radio"]').at(0).setChecked() - await transactionForm.find('input[type="email"]').setValue('user@example.org') - await transactionForm.find('input[type="text"]').setValue('23.45') + await transactionForm + .find('[data-test="input-identifier"]') + .find('input') + .setValue('user@example.org') + await transactionForm.find('[data-test="input-amount"]').find('input').setValue('23.45') await transactionForm.find('textarea').setValue('Make the best of it!') await transactionForm.find('form').trigger('submit') await flushPromises() @@ -91,8 +94,12 @@ describe('Send', () => { }) it('restores the previous data in the formular', () => { - expect(wrapper.find("input[type='email']").vm.$el.value).toBe('user@example.org') - expect(wrapper.find("input[type='text']").vm.$el.value).toBe('23.45') + expect(wrapper.find('[data-test="input-identifier"]').find('input').vm.$el.value).toBe( + 'user@example.org', + ) + expect(wrapper.find('[data-test="input-amount"]').find('input').vm.$el.value).toBe( + '23.45', + ) expect(wrapper.find('textarea').vm.$el.value).toBe('Make the best of it!') }) }) @@ -175,7 +182,10 @@ describe('Send', () => { it('has no email input field', () => { expect( - wrapper.findComponent({ name: 'TransactionForm' }).find('input[type="email"]').exists(), + wrapper + .findComponent({ name: 'TransactionForm' }) + .find('[data-test="input-identifier"]') + .exists(), ).toBe(false) }) @@ -183,7 +193,7 @@ describe('Send', () => { beforeEach(async () => { jest.clearAllMocks() const transactionForm = wrapper.findComponent({ name: 'TransactionForm' }) - await transactionForm.find('input[type="text"]').setValue('34.56') + await transactionForm.find('[data-test="input-amount"]').find('input').setValue('34.56') await transactionForm.find('textarea').setValue('Make the best of it!') await transactionForm.find('form').trigger('submit') await flushPromises() @@ -243,7 +253,7 @@ describe('Send', () => { }) const transactionForm = wrapper.findComponent({ name: 'TransactionForm' }) await transactionForm.findAll('input[type="radio"]').at(1).setChecked() - await transactionForm.find('input[type="text"]').setValue('56.78') + await transactionForm.find('[data-test="input-amount"]').find('input').setValue('56.78') await transactionForm.find('textarea').setValue('Make the best of the link!') await transactionForm.find('form').trigger('submit') await flushPromises() diff --git a/frontend/src/store/store.js b/frontend/src/store/store.js index 7716d00de..4036626d8 100644 --- a/frontend/src/store/store.js +++ b/frontend/src/store/store.js @@ -53,6 +53,9 @@ export const mutations = { hideAmountGDT: (state, hideAmountGDT) => { state.hideAmountGDT = !!hideAmountGDT }, + email: (state, email) => { + state.email = email || '' + }, } export const actions = { @@ -81,6 +84,7 @@ export const actions = { commit('isAdmin', false) commit('hideAmountGDD', false) commit('hideAmountGDT', true) + commit('email', '') localStorage.clear() }, } @@ -109,6 +113,7 @@ try { publisherId: null, hideAmountGDD: null, hideAmountGDT: null, + email: '', }, getters: {}, // Syncronous mutation of the state diff --git a/frontend/src/store/store.test.js b/frontend/src/store/store.test.js index 116594e77..a6a596209 100644 --- a/frontend/src/store/store.test.js +++ b/frontend/src/store/store.test.js @@ -33,6 +33,7 @@ const { hasElopage, hideAmountGDD, hideAmountGDT, + email, } = mutations const { login, logout } = actions @@ -166,6 +167,14 @@ describe('Vuex store', () => { expect(state.hideAmountGDT).toEqual(true) }) }) + + describe('email', () => { + it('sets the state of email', () => { + const state = { email: '' } + email(state, 'peter@luatig.de') + expect(state.email).toEqual('peter@luatig.de') + }) + }) }) describe('actions', () => { @@ -253,9 +262,9 @@ describe('Vuex store', () => { const commit = jest.fn() const state = {} - it('calls eleven commits', () => { + it('calls twelve commits', () => { logout({ commit, state }) - expect(commit).toHaveBeenCalledTimes(11) + expect(commit).toHaveBeenCalledTimes(12) }) it('commits token', () => { @@ -312,6 +321,12 @@ describe('Vuex store', () => { logout({ commit, state }) expect(commit).toHaveBeenNthCalledWith(11, 'hideAmountGDT', true) }) + + it('commits email', () => { + logout({ commit, state }) + expect(commit).toHaveBeenNthCalledWith(12, 'email', '') + }) + // how to get this working? it.skip('calls localStorage.clear()', () => { const clearStorageMock = jest.fn() diff --git a/frontend/src/validation-rules.js b/frontend/src/validation-rules.js index 124ef8528..adb4dc431 100644 --- a/frontend/src/validation-rules.js +++ b/frontend/src/validation-rules.js @@ -2,6 +2,13 @@ import { configure, extend } from 'vee-validate' // eslint-disable-next-line camelcase import { required, email, min, max, is_not } from 'vee-validate/dist/rules' import { checkUsername } from '@/graphql/queries' +import { validate as validateUuid, version as versionUuid } from 'uuid' + +// taken from vee-validate +// eslint-disable-next-line no-useless-escape +const EMAIL_REGEX = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + +const USERNAME_REGEX = /^(?=.{3,20}$)[a-zA-Z0-9]+(?:[_-][a-zA-Z0-9]+?)*$/ export const loadAllRules = (i18nCallback, apollo) => { configure({ @@ -141,7 +148,7 @@ export const loadAllRules = (i18nCallback, apollo) => { extend('usernameUnique', { validate(value) { - if (!value.match(/^(?=.{3,20}$)[a-zA-Z0-9]+(?:[_-][a-zA-Z0-9]+?)*$/)) return true + if (!value.match(USERNAME_REGEX)) return true return apollo .query({ query: checkUsername, @@ -155,4 +162,14 @@ export const loadAllRules = (i18nCallback, apollo) => { }, message: (_, values) => i18nCallback.t('form.validation.username-unique', values), }) + + extend('validIdentifier', { + validate(value) { + const isEmail = !!EMAIL_REGEX.test(value) + const isUsername = !!value.match(USERNAME_REGEX) + const isGradidoId = validateUuid(value) && versionUuid(value) === 4 + return isEmail || isUsername || isGradidoId + }, + message: (_, values) => i18nCallback.t('form.validation.valid-identifier', values), + }) } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 7cc8e5fe5..8eff12aaf 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -14176,6 +14176,11 @@ uuid@^8.3.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +uuid@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" + integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + v8-compile-cache@^2.0.3, v8-compile-cache@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"