diff --git a/__tests__/__utils__/services.ts b/__tests__/__utils__/services.ts index 3c598e158..0e9a77560 100644 --- a/__tests__/__utils__/services.ts +++ b/__tests__/__utils__/services.ts @@ -9,11 +9,7 @@ import { v4 as uuidv4 } from 'uuid' import type { Identity, KratosDB } from '~/context/auth-services' import { Model } from '~/internals/graphql' - -enum MajorDimension { - Rows = 'ROWS', - Columns = 'COLUMNS', -} +import type { MajorDimension } from '~/model' export class MockKratos { identities: Identity[] = [] diff --git a/__tests__/schema/uuid/user.ts b/__tests__/schema/uuid/user.ts index 5f903ee0c..0d77fafa1 100644 --- a/__tests__/schema/uuid/user.ts +++ b/__tests__/schema/uuid/user.ts @@ -17,11 +17,7 @@ import { } from '../../__utils__' import { Model } from '~/internals/graphql' import { Instance } from '~/types' - -enum MajorDimension { - Rows = 'ROWS', - Columns = 'COLUMNS', -} +import { MajorDimension } from '~/model' const client = new Client() const adminUserId = 1 diff --git a/packages/server/src/internals/data-source.ts b/packages/server/src/internals/data-source.ts index 8cd100af2..4599cfcde 100644 --- a/packages/server/src/internals/data-source.ts +++ b/packages/server/src/internals/data-source.ts @@ -1,12 +1,15 @@ import { RESTDataSource } from 'apollo-datasource-rest' import { Context } from '~/context' -import { createChatModel } from '~/model' +import { createGoogleSpreadsheetApiModel, createChatModel } from '~/model' import { createKratosModel } from '~/model/kratos' import { createMailchimpModel } from '~/model/mailchimp' export class ModelDataSource extends RESTDataSource { public chat: ReturnType + public googleSpreadsheetApi: ReturnType< + typeof createGoogleSpreadsheetApiModel + > public mailchimp: ReturnType public kratos: ReturnType @@ -19,6 +22,7 @@ export class ModelDataSource extends RESTDataSource { super() this.chat = createChatModel({ context }) + this.googleSpreadsheetApi = createGoogleSpreadsheetApiModel({ context }) this.mailchimp = createMailchimpModel() this.kratos = createKratosModel({ context }) } diff --git a/packages/server/src/model/google-spreadsheet-api.ts b/packages/server/src/model/google-spreadsheet-api.ts new file mode 100644 index 000000000..e62dcd669 --- /dev/null +++ b/packages/server/src/model/google-spreadsheet-api.ts @@ -0,0 +1,111 @@ +import { either as E, option as O, function as F } from 'fp-ts' +import { NonEmptyArray } from 'fp-ts/lib/NonEmptyArray' +import * as t from 'io-ts' +import { nonEmptyArray } from 'io-ts-types/lib/nonEmptyArray' +import { URL } from 'url' + +import { Context } from '~/context' +import { addContext, ErrorEvent } from '~/error-event' +import { createLegacyQuery } from '~/internals/data-source-helper' + +export enum MajorDimension { + Rows = 'ROWS', + Columns = 'COLUMNS', +} + +const CellValues = nonEmptyArray(t.array(t.string)) +// Syntax manually de-sugared because API Exporter doesn't support import() types yet +// export type CellValues = t.TypeOf +export type CellValues = NonEmptyArray + +const ValueRange = t.intersection([ + t.partial({ + values: CellValues, + }), + t.type({ + range: t.string, + majorDimension: t.string, + }), +]) +type ValueRange = t.TypeOf + +interface Arguments { + spreadsheetId: string + range: string + majorDimension?: MajorDimension +} + +export function createGoogleSpreadsheetApiModel({ + context, +}: { + context: Pick +}) { + const getValues = createLegacyQuery< + Arguments, + E.Either + >( + { + type: 'google-spreadsheets-api', + enableSwr: true, + getCurrentValue: async (args) => { + const { spreadsheetId, range } = args + const majorDimension = args.majorDimension ?? MajorDimension.Rows + const url = new URL( + `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${range}`, + ) + url.searchParams.append('majorDimension', majorDimension) + const apiSecret = process.env.GOOGLE_SPREADSHEET_API_SECRET + url.searchParams.append('key', apiSecret) + + const specifyErrorLocation = E.mapLeft( + addContext({ + location: 'googleSpreadSheetApi', + locationContext: { ...args }, + }), + ) + + try { + const response = await fetch(url.toString()) + + return F.pipe( + ValueRange.decode(await response.json()), + E.mapLeft(() => { + return { error: new Error('invalid response') } + }), + E.map((v) => v.values), + E.chain(E.fromNullable({ error: new Error('range is empty') })), + specifyErrorLocation, + ) + } catch (error) { + return specifyErrorLocation(E.left({ error: E.toError(error) })) + } + }, + staleAfter: { hours: 1 }, + getKey: (args) => { + const { spreadsheetId, range } = args + const majorDimension = args.majorDimension ?? MajorDimension.Rows + return `spreadsheet/${spreadsheetId}/${range}/${majorDimension}` + }, + getPayload: (key) => { + const parts = key.split('/') + return parts.length === 4 && parts[0] === 'spreadsheet' + ? O.some({ + spreadsheetId: parts[1], + range: parts[2], + majorDimension: parts[3] as MajorDimension, + }) + : O.none + }, + examplePayload: { + spreadsheetId: 'abc', + range: 'Tabellenblatt1!A:F', + majorDimension: MajorDimension.Rows, + }, + }, + context, + ) + + return { + getValues, + } +} diff --git a/packages/server/src/model/index.ts b/packages/server/src/model/index.ts index 6f9ca43b6..8ac4a2a1d 100644 --- a/packages/server/src/model/index.ts +++ b/packages/server/src/model/index.ts @@ -1,11 +1,14 @@ import { createChatModel } from './chat' +import { createGoogleSpreadsheetApiModel } from './google-spreadsheet-api' import { createKratosModel } from './kratos' import { createMailchimpModel } from './mailchimp' export * from './chat' +export * from './google-spreadsheet-api' export const modelFactories = { chat: createChatModel, + googleSpreadsheetApi: createGoogleSpreadsheetApiModel, mailChimp: createMailchimpModel, kratos: createKratosModel, } diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index 4bc5c3642..d98707e66 100644 --- a/packages/server/src/schema/uuid/user/resolvers.ts +++ b/packages/server/src/schema/uuid/user/resolvers.ts @@ -2,10 +2,8 @@ import * as serloAuth from '@serlo/authorization' import { instanceToScope, Scope } from '@serlo/authorization' import { createHash } from 'crypto' import { array as A, either as E, function as F, option as O } from 'fp-ts' -import { NonEmptyArray } from 'fp-ts/lib/NonEmptyArray' import * as t from 'io-ts' import * as R from 'ramda' -import { URL } from 'url' import { resolveUnrevisedEntityIds } from '../abstract-entity/resolvers' import { UuidResolver } from '../abstract-uuid/resolvers' @@ -27,6 +25,7 @@ import { generateRole, isGlobalRole, } from '~/internals/graphql' +import { CellValues, MajorDimension } from '~/model' import { EntityDecoder, UserDecoder } from '~/model/decoder' import { getPermissionsForRole, @@ -38,11 +37,6 @@ import { createThreadResolvers } from '~/schema/thread/utils' import { createUuidResolvers } from '~/schema/uuid/abstract-uuid/utils' import { Instance, Resolvers } from '~/types' -enum MajorDimension { - Rows = 'ROWS', - Columns = 'COLUMNS', -} - export const ActiveUserIdsResolver = createCachedResolver< Record, number[] @@ -206,12 +200,12 @@ export const resolvers: Resolvers = { User: { ...createUuidResolvers(), ...createThreadResolvers(), - async motivation(user, _args, _context) { - const spreadsheetId = process.env.GOOGLE_SPREADSHEET_API_MOTIVATION - const range = 'Formularantworten!B:D' - + async motivation(user, _args, context) { return F.pipe( - await getSpreadsheetValues({ spreadsheetId, range }), + await context.dataSources.model.googleSpreadsheetApi.getValues({ + spreadsheetId: process.env.GOOGLE_SPREADSHEET_API_MOTIVATION, + range: 'Formularantworten!B:D', + }), E.mapLeft( addContext({ location: 'motivationSpreadsheet', @@ -633,14 +627,11 @@ async function fetchActivityByType( return result } -async function activeDonorIDs(_context: Context) { - const spreadsheetId = process.env.GOOGLE_SPREADSHEET_API_ACTIVE_DONORS - const range = 'Tabellenblatt1!A:A' - +async function activeDonorIDs(context: Context) { return F.pipe( - await getSpreadsheetValues({ - spreadsheetId, - range, + await context.dataSources.model.googleSpreadsheetApi.getValues({ + spreadsheetId: process.env.GOOGLE_SPREADSHEET_API_ACTIVE_DONORS, + range: 'Tabellenblatt1!A:A', majorDimension: MajorDimension.Columns, }), extractIDsFromFirstColumn, @@ -689,50 +680,3 @@ async function deleteKratosUser( await authServices.kratos.admin.deleteIdentity({ id: identity.id }) } } - -type CellValues = NonEmptyArray - -interface GetSpreadsheetValuesArgs { - spreadsheetId: string - range: string - majorDimension?: MajorDimension -} - -async function getSpreadsheetValues( - args: GetSpreadsheetValuesArgs, -): Promise> { - const { spreadsheetId, range } = args - const majorDimension = args.majorDimension ?? MajorDimension.Rows - const url = new URL( - `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${range}`, - ) - url.searchParams.append('majorDimension', majorDimension) - const apiSecret = process.env.GOOGLE_SPREADSHEET_API_SECRET - url.searchParams.append('key', apiSecret) - - const specifyErrorLocation = E.mapLeft( - addContext({ - location: 'googleSpreadSheetApi', - locationContext: { ...args }, - }), - ) - - try { - const response = await fetch(url.toString()) - const data = (await response.json()) as { values?: string[][] } - - if ( - !data.values || - !Array.isArray(data.values) || - data.values.length === 0 - ) { - return specifyErrorLocation( - E.left({ error: new Error('invalid response or empty range') }), - ) - } - - return specifyErrorLocation(E.right(data.values as CellValues)) - } catch (error) { - return specifyErrorLocation(E.left({ error: E.toError(error) })) - } -}