diff --git a/backend/package.json b/backend/package.json index 8534b9434..6889c5591 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,11 +11,11 @@ "build": "tsc --build && mkdir -p build/src/emails/templates/ && cp -r src/emails/templates/* build/src/emails/templates/ && mkdir -p build/src/locales/ && cp -r src/locales/*.json build/src/locales/", "clean": "tsc --build --clean", "start": "cross-env TZ=UTC TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/src/index.js", - "dev": "cross-env TZ=UTC nodemon -w src --ext ts --exec ts-node -r tsconfig-paths/register src/index.ts", + "dev": "cross-env TZ=UTC nodemon -w src --ext ts,pug,json,css --exec ts-node -r tsconfig-paths/register src/index.ts", "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 +}