Skip to content

Commit

Permalink
feat(user): reintroduce model google-spreadsheet-api
Browse files Browse the repository at this point in the history
Since removing it caused a bug related to cache.
We could fix the bug, but it is not worthy since the repo is in
maintenance.

Revert #1697
  • Loading branch information
hugotiburtino committed Aug 6, 2024
1 parent f28ed50 commit 6d4256e
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 77 deletions.
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 @@ -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'

Check failure on line 20 in __tests__/schema/uuid/user.ts

View workflow job for this annotation

GitHub Actions / eslint

`~/model` import should occur before import of `~/types`

const client = new Client()
const adminUserId = 1
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) }))
}
}

0 comments on commit 6d4256e

Please sign in to comment.