Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revert #1697 -> model google spreadsheet #1710

Merged
merged 2 commits into from
Aug 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions __tests__/__utils__/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand Down
6 changes: 1 addition & 5 deletions __tests__/schema/uuid/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,9 @@ import {
createFakeIdentity,
} from '../../__utils__'
import { Model } from '~/internals/graphql'
import { MajorDimension } from '~/model'
import { Instance } from '~/types'

enum MajorDimension {
Rows = 'ROWS',
Columns = 'COLUMNS',
}

const client = new Client()
const adminUserId = 1
const loginUserId = 9
Expand Down
6 changes: 5 additions & 1 deletion packages/server/src/internals/data-source.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createChatModel>
public googleSpreadsheetApi: ReturnType<
typeof createGoogleSpreadsheetApiModel
>
public mailchimp: ReturnType<typeof createMailchimpModel>
public kratos: ReturnType<typeof createKratosModel>

Expand All @@ -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 })
}
Expand Down
111 changes: 111 additions & 0 deletions packages/server/src/model/google-spreadsheet-api.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CellValues>
export type CellValues = NonEmptyArray<string[]>

const ValueRange = t.intersection([
t.partial({
values: CellValues,
}),
t.type({
range: t.string,
majorDimension: t.string,
}),
])
type ValueRange = t.TypeOf<typeof ValueRange>

interface Arguments {
spreadsheetId: string
range: string
majorDimension?: MajorDimension
}

export function createGoogleSpreadsheetApiModel({
context,
}: {
context: Pick<Context, 'swrQueue' | 'cache'>
}) {
const getValues = createLegacyQuery<
Arguments,
E.Either<ErrorEvent, CellValues>
>(
{
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,
}
}
3 changes: 3 additions & 0 deletions packages/server/src/model/index.ts
Original file line number Diff line number Diff line change
@@ -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,
}
76 changes: 10 additions & 66 deletions packages/server/src/schema/uuid/user/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -27,6 +25,7 @@ import {
generateRole,
isGlobalRole,
} from '~/internals/graphql'
import { CellValues, MajorDimension } from '~/model'
import { EntityDecoder, UserDecoder } from '~/model/decoder'
import {
getPermissionsForRole,
Expand All @@ -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<string, never>,
number[]
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -689,50 +680,3 @@ async function deleteKratosUser(
await authServices.kratos.admin.deleteIdentity({ id: identity.id })
}
}

type CellValues = NonEmptyArray<string[]>

interface GetSpreadsheetValuesArgs {
spreadsheetId: string
range: string
majorDimension?: MajorDimension
}

async function getSpreadsheetValues(
args: GetSpreadsheetValuesArgs,
): Promise<E.Either<ErrorEvent, CellValues>> {
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) }))
}
}