From f7a472c68eb0cbca4df92d54217f8bef4b3ab3f5 Mon Sep 17 00:00:00 2001 From: Michael Reichenbach <755327+Silthus@users.noreply.github.com> Date: Sun, 11 Feb 2024 07:04:05 +0100 Subject: [PATCH] feat(import): rework bulk import to use gql api --- .../{import.test.ts => bulkImport.test.ts} | 81 +++++--- src/__tests__/import-example.json | 100 +++------- src/auth/local-dev/middleware.ts | 21 +- src/auth/middleware.ts | 25 +-- src/auth/permissions.ts | 47 ++--- src/auth/rules.ts | 17 +- src/db/AreaTypes.ts | 25 +-- src/db/BulkImportTypes.ts | 57 ++++++ src/db/import/json/import-json.ts | 142 -------------- src/db/import/json/index.ts | 26 --- src/db/import/json/request-handler.ts | 18 -- src/graphql/area/AreaMutations.ts | 48 +++-- src/graphql/schema/AreaEdit.gql | 118 ++++++++++++ src/model/BulkImportDataSource.ts | 181 ++++++++++++++++++ .../__tests__/BulkDataSource.test.ts} | 82 ++++---- src/server.ts | 34 ++-- src/types.ts | 9 +- src/utils/testUtils.ts | 42 ++-- 18 files changed, 590 insertions(+), 483 deletions(-) rename src/__tests__/{import.test.ts => bulkImport.test.ts} (55%) create mode 100644 src/db/BulkImportTypes.ts delete mode 100644 src/db/import/json/import-json.ts delete mode 100644 src/db/import/json/index.ts delete mode 100644 src/db/import/json/request-handler.ts create mode 100644 src/model/BulkImportDataSource.ts rename src/{db/import/json/__tests__/import-json.test.ts => model/__tests__/BulkDataSource.test.ts} (69%) diff --git a/src/__tests__/import.test.ts b/src/__tests__/bulkImport.test.ts similarity index 55% rename from src/__tests__/import.test.ts rename to src/__tests__/bulkImport.test.ts index 755ba6e3..1c7b9674 100644 --- a/src/__tests__/import.test.ts +++ b/src/__tests__/bulkImport.test.ts @@ -7,10 +7,28 @@ import {muuidToString} from "../utils/helpers.js"; import MutableAreaDataSource from "../model/MutableAreaDataSource.js"; import exampleImportData from './import-example.json' assert {type: 'json'}; import {AreaType} from "../db/AreaTypes.js"; -import {BulkImportResult} from "../db/import/json/import-json"; +import {BulkImportResultType} from "../db/BulkImportTypes.js"; +import MutableClimbDataSource from "../model/MutableClimbDataSource.js"; + +describe('bulkImportAreas', () => { + const query = ` + mutation bulkImportAreas($input: BulkImportInput!) { + bulkImportAreas(input: $input) { + addedAreas { + uuid + } + updatedAreas { + uuid + } + addedClimbs { + id + } + updatedClimbs { + id + } + } + ` -describe('/import', () => { - const endpoint = '/import' let server: ApolloServer let user: muuid.MUUID let userUuid: string @@ -19,6 +37,7 @@ describe('/import', () => { let testArea: AreaType let areas: MutableAreaDataSource + let climbs: MutableClimbDataSource beforeAll(async () => { ({server, inMemoryDB, app} = await setUpServer()) @@ -26,11 +45,13 @@ describe('/import', () => { // "59f1d95a-627d-4b8c-91b9-389c7424cb54" instead of base64 "WfHZWmJ9S4yRuTicdCTLVA==". user = muuid.mode('relaxed').v4() userUuid = muuidToString(user) + + areas = MutableAreaDataSource.getInstance() + climbs = MutableClimbDataSource.getInstance() }) beforeEach(async () => { await inMemoryDB.clear() - areas = MutableAreaDataSource.getInstance() await areas.addCountry('usa') testArea = await areas.addArea(user, "Test Area", null, "us") }) @@ -43,31 +64,34 @@ describe('/import', () => { it('should return 403 if no user', async () => { const res = await queryAPI({ app, - endpoint, - body: exampleImportData + query, + operationName: 'bulkImportAreas', + variables: {input: exampleImportData} }) - expect(res.status).toBe(403) + expect(res.status).toBe(400) expect(res.text).toBe('Forbidden') }) it('should return 403 if user is not an editor', async () => { const res = await queryAPI({ app, - endpoint, userUuid, - body: exampleImportData + query, + operationName: 'bulkImportAreas', + variables: {input: exampleImportData} }) - expect(res.status).toBe(403) + expect(res.status).toBe(400) expect(res.text).toBe('Forbidden') }) it('should return 200 if user is an editor', async () => { const res = await queryAPI({ app, - endpoint, userUuid, roles: ['editor'], - body: exampleImportData + query, + operationName: 'bulkImportAreas', + variables: {input: exampleImportData} }) expect(res.status).toBe(200) }) @@ -75,31 +99,34 @@ describe('/import', () => { it('should import data', async () => { const res = await queryAPI({ app, - endpoint, userUuid, roles: ['editor'], - body: { - areas: [ - ...exampleImportData.areas, - { - id: testArea.metadata.area_id.toUUID().toString(), - areaName: "Updated Test Area", - } - ], - }, + query, + operationName: 'bulkImportAreas', + variables: { + input: { + areas: [ + ...exampleImportData.areas, + { + id: testArea.metadata.area_id.toUUID().toString(), + areaName: "Updated Test Area", + } + ] + } + } }); expect(res.status).toBe(200) - const result = res.body as BulkImportResult - expect(result.addedAreaIds.length).toBe(4) + const result = res.body as BulkImportResultType + expect(result.addedAreas.length).toBe(4) - const committedAreas = await Promise.all(result.addedAreaIds.map((areaId: string) => areas.findOneAreaByUUID(muuid.from(areaId)))); + const committedAreas = await Promise.all(result.addedAreas.map((area) => areas.findOneAreaByUUID(area.metadata.area_id))); expect(committedAreas.length).toBe(4); - const committedClimbs = await Promise.all(result.climbIds.map((id: string) => areas.findOneClimbByUUID(muuid.from(id)))); + const committedClimbs = await Promise.all(result.addedOrUpdatedClimbs.map((climb) => climbs.findOneClimbByMUUID(climb._id))); expect(committedClimbs.length).toBe(2); - const updatedAreas = await Promise.all(result.updatedAreaIds.map((areaId: any) => areas.findOneAreaByUUID(muuid.from(areaId)))); + const updatedAreas = await Promise.all(result.updatedAreas.map((area) => areas.findOneAreaByUUID(area.metadata.area_id))); expect(updatedAreas.length).toBe(1); expect(updatedAreas[0].area_name).toBe("Updated Test Area"); }) diff --git a/src/__tests__/import-example.json b/src/__tests__/import-example.json index 1546849a..2938f09a 100644 --- a/src/__tests__/import-example.json +++ b/src/__tests__/import-example.json @@ -9,64 +9,52 @@ "children": [ { "areaName": "Indian Creek", + "description": "Indian Creek is a crack climbing mecca in the southeastern region of Utah, USA. Located within the [Bears Ears National Monument](https://en.wikipedia.org/wiki/Bears_Ears_National_Monument).", + "lng": -109.5724044642857, + "lat": 38.069429035714286, "children": [ { "areaName": "Supercrack Buttress", + "gradeContext": "US", + "description": "", + "lng": -109.54552, + "lat": 38.03635, + "bbox": [ + -109.54609091005857, + 38.03590033981814, + -109.54494908994141, + 38.03679966018186 + ], "climbs": [ { "name": "The Key Flake", "grade": "5.10", "fa": "unknown", - "type": { + "disciplines": { "trad": true }, "safety": "UNSPECIFIED", - "metadata": { - "lnglat": { - "type": "Point", - "coordinates": [ - -109.54552, - 38.03635 - ] - }, - "left_right_index": 1 - }, - "content": { - "description": "Cool off-width that requires off-width and face skills.", - "protection": "Anchors hidden up top. Need 80m to make it all the way down.", - "location": "Opposite keyhole flake. Obvious right leaning offwidth that starts atop 20 ft boulder." - } + "lng": -109.54552, + "lat": 38.03635, + "leftRightIndex": 1, + "description": "Cool off-width that requires off-width and face skills.", + "protection": "Anchors hidden up top. Need 80m to make it all the way down.", + "location": "Opposite keyhole flake. Obvious right leaning offwidth that starts atop 20 ft boulder." }, { "name": "Incredible Hand Crack", "grade": "5.10", "fa": "Rich Perch, John Bragg, Doug Snively, and Anne Tarver, 1978", - "type": { + "disciplines": { "trad": true }, - "safety": "UNSPECIFIED", - "metadata": { - "lnglat": { - "type": "Point", - "coordinates": [ - -109.54552, - 38.03635 - ] - }, - "left_right_index": 2 - }, - "content": { - "description": "Route starts at the top of the trail from the parking lot to Supercrack Buttress.", - "protection": "Cams from 2-2.5\". Heavy on 2.5\" (#2 Camalot)", - "location": "" - }, + "left_right_index": 2, + "description": "Route starts at the top of the trail from the parking lot to Supercrack Buttress.", + "protection": "Cams from 2-2.5\". Heavy on 2.5\" (#2 Camalot)", "pitches": [ { "pitchNumber": 1, "grade": "5.10", - "disciplines": { - "trad": true - }, "length": 100, "boltsCount": 0, "description": "A classic hand crack that widens slightly towards the top. Requires a range of cam sizes. Sustained and excellent quality." @@ -74,52 +62,14 @@ { "pitchNumber": 2, "grade": "5.9", - "disciplines": { - "trad": true - }, "length": 30, - "boltsCount": 0, "description": "Easier climbing with good protection. Features a mix of crack sizes. Shorter than the first pitch but equally enjoyable." } ] } - ], - "gradeContext": "US", - "metadata": { - "isBoulder": false, - "isDestination": false, - "leaf": true, - "lnglat": { - "type": "Point", - "coordinates": [ - -109.54552, - 38.03635 - ] - }, - "bbox": [ - -109.54609091005857, - 38.03590033981814, - -109.54494908994141, - 38.03679966018186 - ] - }, - "content": { - "description": "" - } - } - ], - "metadata": { - "lnglat": { - "type": "Point", - "coordinates": [ - -109.5724044642857, - 38.069429035714286 ] } - }, - "content": { - "description": "Indian Creek is a crack climbing mecca in the southeastern region of Utah, USA. Located within the [Bears Ears National Monument](https://en.wikipedia.org/wiki/Bears_Ears_National_Monument)." - } + ] } ] } diff --git a/src/auth/local-dev/middleware.ts b/src/auth/local-dev/middleware.ts index 5df894b0..d0407cd0 100644 --- a/src/auth/local-dev/middleware.ts +++ b/src/auth/local-dev/middleware.ts @@ -2,31 +2,20 @@ * This file is a mod of src/auth/middleware.ts and is used when starting the server via `yarn serve-dev` * It bypasses the authentication for local development */ -import muuid, { MUUID } from 'uuid-mongodb' -import { AuthUserType } from '../../types.js' -import { logger } from '../../logger.js' +import muuid, {MUUID} from 'uuid-mongodb' +import {AuthUserType} from '../../types.js' +import {logger} from '../../logger.js' export const localDevBypassAuthContext = (() => { const testUUID: MUUID = muuid.v4() - return async ({ req }): Promise => { + return async ({req}): Promise => { const user: AuthUserType = { roles: ['user_admin', 'org_admin', 'editor'], uuid: testUUID, isBuilder: false } logger.info(`The user.roles for this session is: ${user.roles.toString()}`) - return { user } + return {user} } })() - -export const localDevBypassAuthMiddleware = async (req, res, next): Promise => { - req.user = { - roles: ['user_admin', 'org_admin', 'editor'], - uuid: muuid.v4(), - isBuilder: false - } - req.userId = req.user.uuid - req.token = 'local-dev-bypass' - next() -} diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index 3cb68ecd..f86548fd 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -1,12 +1,12 @@ import muid from 'uuid-mongodb' -import { AuthUserType } from '../types.js' -import { verifyJWT } from './util.js' -import { logger } from '../logger.js' +import {AuthUserType} from '../types.js' +import {verifyJWT} from './util.js' +import {logger} from '../logger.js' /** * Create a middleware context for Apollo server */ -export const createContext = async ({ req }): Promise => { +export const createContext = async ({req}): Promise => { try { return await validateTokenAndExtractUser(req) } catch (e) { @@ -15,21 +15,8 @@ export const createContext = async ({ req }): Promise => { } } -export const authMiddleware = async (req, res, next): Promise => { - try { - const { user, token } = await validateTokenAndExtractUser(req) - req.user = user - req.userId = user.uuid - req.token = token - next() - } catch (e) { - logger.error(`Can't verify JWT token ${e.toString() as string}`) - res.status(401).send('Unauthorized') - } -} - -async function validateTokenAndExtractUser (req: Request): Promise<{ user: AuthUserType, token: string }> { - const { headers } = req +async function validateTokenAndExtractUser(req: Request): Promise<{ user: AuthUserType, token: string }> { + const {headers} = req // eslint-disable-next-line @typescript-eslint/dot-notation const authHeader = String(headers?.['authorization'] ?? '') if (!authHeader.startsWith('Bearer ')) { diff --git a/src/auth/permissions.ts b/src/auth/permissions.ts index 59e2925e..86a925ca 100644 --- a/src/auth/permissions.ts +++ b/src/auth/permissions.ts @@ -1,28 +1,29 @@ -import { shield, allow, and, or } from 'graphql-shield' -import { isEditor, isUserAdmin, isOwner, isValidEmail, isMediaOwner } from './rules.js' +import {allow, and, or, shield} from 'graphql-shield' +import {isEditor, isMediaOwner, isOwner, isUserAdmin, isValidEmail} from './rules.js' const permissions = shield({ - Query: { - '*': allow + Query: { + '*': allow + }, + Mutation: { + addOrganization: isUserAdmin, + setDestinationFlag: isEditor, + removeArea: isEditor, + addArea: isEditor, + updateArea: isEditor, + updateClimbs: isEditor, + deleteClimbs: isEditor, + bulkImportAreas: isEditor, + updateUserProfile: and(isOwner, isValidEmail), + addEntityTag: or(isMediaOwner, isUserAdmin), + removeEntityTag: or(isMediaOwner, isUserAdmin), + addMediaObjects: or(isOwner), + deleteMediaObject: or(isMediaOwner, isUserAdmin) + } }, - Mutation: { - addOrganization: isUserAdmin, - setDestinationFlag: isEditor, - removeArea: isEditor, - addArea: isEditor, - updateArea: isEditor, - updateClimbs: isEditor, - deleteClimbs: isEditor, - updateUserProfile: and(isOwner, isValidEmail), - addEntityTag: or(isMediaOwner, isUserAdmin), - removeEntityTag: or(isMediaOwner, isUserAdmin), - addMediaObjects: or(isOwner), - deleteMediaObject: or(isMediaOwner, isUserAdmin) - } -}, -{ - allowExternalErrors: true, - fallbackRule: allow -}) + { + allowExternalErrors: true, + fallbackRule: allow + }) export default permissions diff --git a/src/auth/rules.ts b/src/auth/rules.ts index 68de82de..530194cc 100644 --- a/src/auth/rules.ts +++ b/src/auth/rules.ts @@ -1,21 +1,12 @@ -import { inputRule, rule } from 'graphql-shield' +import {inputRule, rule} from 'graphql-shield' import MediaDataSource from '../model/MutableMediaDataSource.js' -import { MediaObjectGQLInput } from '../db/MediaObjectTypes.js' +import {MediaObjectGQLInput} from '../db/MediaObjectTypes.js' export const isEditor = rule()(async (parent, args, ctx, info) => { return _hasUserUuid(ctx) && ctx.user.roles.includes('editor') }) -export const hasEditorRoleMiddleware = async (req, res, next): Promise => { - const roles: string[] = req.user?.roles ?? [] - if (_hasUserUuid(req) && roles.includes('editor')) { - next() - } else { - res.status(403).send('Forbidden') - } -} - export const isUserAdmin = rule()(async (parent, args, ctx, info) => { return _hasUserUuid(ctx) && ctx.user.roles.includes('user_admin') }) @@ -29,7 +20,7 @@ export const isOwner = rule()(async (parent, args, ctx, info) => { if (!_hasUserUuid(ctx)) return false if (Array.isArray(args.input)) { return (args.input as MediaObjectGQLInput[]).every( - ({ userUuid }) => ctx.user.uuid.toUUID().toString() === userUuid) + ({userUuid}) => ctx.user.uuid.toUUID().toString() === userUuid) } return ctx.user.uuid.toUUID().toString() === args.input.userUuid }) @@ -52,7 +43,7 @@ export const isValidEmail = inputRule()( yup.object({ email: yup.string().email('Please provide a valid email') }), - { abortEarly: false } + {abortEarly: false} ) interface Context { diff --git a/src/db/AreaTypes.ts b/src/db/AreaTypes.ts index 7b1c1bc4..0a51b5f1 100644 --- a/src/db/AreaTypes.ts +++ b/src/db/AreaTypes.ts @@ -1,12 +1,12 @@ import mongoose from 'mongoose' -import { MUUID } from 'uuid-mongodb' +import {MUUID} from 'uuid-mongodb' -import { BBox, Point, Polygon } from '@turf/helpers' -import { GradeContexts } from '../GradeUtils.js' -import { AuthorMetadata } from '../types.js' -import { ChangeRecordMetadataType } from './ChangeLogType.js' -import { ClimbType } from './ClimbTypes.js' -import { ExperimentalAuthorType } from './UserTypes.js' +import {BBox, Point, Polygon} from '@turf/helpers' +import {GradeContexts} from '../GradeUtils.js' +import {AuthorMetadata} from '../types.js' +import {ChangeRecordMetadataType} from './ChangeLogType.js' +import {ClimbType} from './ClimbTypes.js' +import {ExperimentalAuthorType} from './UserTypes.js' export type AreaDocumnent = mongoose.Document & AreaType @@ -34,7 +34,7 @@ export type AreaType = IAreaProps & { * they may be hard to locate based on the contents of this object. * See AreaType for the reified version of this object, and always use it * if you are working with data that exists inside the database. -*/ + */ export interface IAreaProps extends AuthorMetadata { _id: mongoose.Types.ObjectId /** @@ -78,7 +78,7 @@ export interface IAreaProps extends AuthorMetadata { /** * computed aggregations on this document. See the AggregateType documentation for * more information. - */ + */ aggregate: AggregateType /** * User-composed content that makes up most of the user-readable data in the system. @@ -154,6 +154,7 @@ export interface IAreaMetadata { */ polygon?: Polygon } + export interface IAreaContent { /** longform to mediumform description of this area. * Remembering that areas can be the size of countries, or as precise as a single cliff/boulder, @@ -190,11 +191,13 @@ export interface CountByGroupType { count: number label: string } + export interface AggregateType { byGrade: CountByGroupType[] byDiscipline: CountByDisciplineType byGradeBand: CountByGradeBandType } + export interface CountByDisciplineType { trad?: DisciplineStatsType sport?: DisciplineStatsType @@ -223,7 +226,7 @@ export interface CountByGradeBandType { } /** The audit trail comprises a set of controlled events that may occur in relation - * to user actiion on core data. The enumeration herein defines the set of events + * to user action on core data. The enumeration herein defines the set of events * that may occur, and short documentation of what they mean */ export enum OperationType { @@ -242,7 +245,7 @@ export enum OperationType { * specific field's boolean state. */ updateDestination = 'updateDestination', - /** signals that a user has pushed new user-changable data has been pushed into an area document. */ + /** signals that a user has pushed new user-changeable data has been pushed into an area document. */ updateArea = 'updateArea', /** Set areas' sorting index */ diff --git a/src/db/BulkImportTypes.ts b/src/db/BulkImportTypes.ts new file mode 100644 index 00000000..35238c79 --- /dev/null +++ b/src/db/BulkImportTypes.ts @@ -0,0 +1,57 @@ +import {AreaType} from "./AreaTypes.js"; +import {ClimbType, DisciplineType, SafetyType} from "./ClimbTypes.js"; +import {MUUID} from "uuid-mongodb"; +import {ExperimentalAuthorType} from "./UserTypes.js"; + +export interface BulkImportResultType { + addedAreas: AreaType[] + updatedAreas: AreaType[] + addedOrUpdatedClimbs: ClimbType[] +} + +export interface BulkImportInputType { + areas: BulkImportAreaInputType[] +} + +export interface BulkImportAreaInputType { + uuid?: MUUID + areaName?: string + description?: string + countryCode?: string + gradeContext?: string + leftRightIndex?: number + lng?: number + lat?: number + bbox?: [number, number, number, number] + children?: BulkImportAreaInputType[] + climbs?: BulkImportClimbInputType[] +} + +export interface BulkImportClimbInputType { + uuid?: MUUID + name?: string + grade: string + disciplines: DisciplineType + safety?: SafetyType + lng?: number + lat?: number + leftRightIndex?: number + description?: string + location?: string + protection?: string + fa?: string + length?: number + boltsCount?: number + experimentalAuthor?: ExperimentalAuthorType + pitches?: BulkImportPitchesInputType[] +} + +export interface BulkImportPitchesInputType { + id?: MUUID + pitchNumber: number + grade: string + disciplines?: DisciplineType + description?: string + length?: number + boltsCount?: number +} \ No newline at end of file diff --git a/src/db/import/json/import-json.ts b/src/db/import/json/import-json.ts deleted file mode 100644 index 5362eca2..00000000 --- a/src/db/import/json/import-json.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { Point } from '@turf/helpers' -import mongoose from 'mongoose' -import muuid, { MUUID } from 'uuid-mongodb' -import { logger } from '../../../logger.js' -import MutableAreaDataSource from '../../../model/MutableAreaDataSource.js' -import MutableClimbDataSource from '../../../model/MutableClimbDataSource.js' -import { AreaType } from '../../AreaTypes.js' -import { ClimbChangeInputType, ClimbType } from '../../ClimbTypes.js' -import { withTransaction } from '../../../utils/helpers' - -export type AreaJson = Partial< -Omit -> & { - id?: MUUID - areaName: string - countryCode?: string - metadata?: Partial> & { - lnglat?: [number, number] | Point - } - children?: AreaJson[] - climbs?: ClimbChangeInputType[] -} - -export type ClimbJson = Partial> & { - metadata?: Omit -} - -export interface BulkImportOptions { - user: MUUID - json: { areas?: AreaJson[] } - session?: mongoose.ClientSession - areas?: MutableAreaDataSource - climbs?: MutableClimbDataSource -} - -export interface BulkImportResult { - addedAreaIds: string[] - updatedAreaIds: string[] - climbIds: string[] - errors: Error[] -} - -/** - * - * @param json the json to import formatted in a valid database format - * @returns a list of ids of the areas that were imported - */ -export async function bulkImportJson ({ - user, - json, - session: _session, - areas = MutableAreaDataSource.getInstance(), - climbs = MutableClimbDataSource.getInstance() -}: BulkImportOptions): Promise { - const result: BulkImportResult = { - addedAreaIds: [], - updatedAreaIds: [], - climbIds: [], - errors: [] - } - logger.debug('starting bulk import session...') - const session = _session ?? (await mongoose.startSession()) - try { - return await withTransaction(session, async () => { - logger.info('starting bulk import...', json) - return await _bulkImportJson({ user, json, areas, climbs, session }) - }) ?? result - } catch (e) { - logger.error('bulk import failed', e) - result.errors.push(e) - } finally { - await session.endSession() - } - return result -} - -async function _bulkImportJson ({ - user, - json, - session, - areas = MutableAreaDataSource.getInstance(), - climbs = MutableClimbDataSource.getInstance() -}: BulkImportOptions): Promise { - const addOrUpdateArea = async ( - node: AreaJson, - parentUuid?: MUUID - ): Promise => { - const result: BulkImportResult = { - addedAreaIds: [], - updatedAreaIds: [], - climbIds: [], - errors: [] - } - let area: AreaType | null - if (node.id !== undefined && node.id !== null) { - area = await areas?.updateAreaWith({ user, areaUuid: muuid.from(node.id), document: node, session }) - if (area != null) { result.updatedAreaIds.push(area.metadata.area_id.toUUID().toString()) } - } else if (node.areaName !== undefined) { - area = await areas.addAreaWith({ - user, - areaName: node.areaName, - countryCode: node.countryCode, - parentUuid, - session - }) - result.addedAreaIds.push(area?.metadata.area_id.toUUID().toString()) - } else { - throw new Error('areaName or id is required') - } - if ((node.children != null) && (area != null)) { - for (const child of node.children) { - const childResult = await addOrUpdateArea(child, area.metadata.area_id) - result.updatedAreaIds.push(...childResult.updatedAreaIds) - result.addedAreaIds.push(...childResult.addedAreaIds) - result.climbIds.push(...childResult.climbIds) - } - } - if ((node.climbs != null) && (area != null)) { - const climbIds = await climbs.addOrUpdateClimbsWith({ - userId: user, - parentId: area.metadata.area_id, - changes: [...node.climbs ?? []], - session - }) - result.climbIds.push(...climbIds) - } - return result - } - - const results = await Promise.all(json?.areas?.map(async (node) => await (addOrUpdateArea(node) ?? {})) ?? []) - return results.reduce((acc, r) => ({ - addedAreaIds: [...acc.addedAreaIds, ...r.addedAreaIds], - updatedAreaIds: [...acc.updatedAreaIds, ...r.updatedAreaIds], - climbIds: [...acc.climbIds, ...r.climbIds], - errors: [...acc.errors, ...r.errors] - }), { - addedAreaIds: [], - updatedAreaIds: [], - climbIds: [], - errors: [] - }) -} diff --git a/src/db/import/json/index.ts b/src/db/import/json/index.ts deleted file mode 100644 index 4456ca84..00000000 --- a/src/db/import/json/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import fs from 'node:fs' -import { logger } from '../../../logger.js' -import { connectDB, gracefulExit } from '../../index.js' -import { bulkImportJson } from './import-json.js' - -const contentDir: string = process.env.CONTENT_BASEDIR ?? '' - -if (contentDir === '') { - logger.error('Missing CONTENT_BASEDIR env') - process.exit(1) -} - -const main = async (): Promise => { - const dataFile = `${contentDir}/import.json` - if (!fs.existsSync(dataFile)) { - logger.error(`Missing ${dataFile}`) - process.exit(1) - } - - const data = JSON.parse(fs.readFileSync(dataFile, 'utf8')) - await bulkImportJson(data) - - await gracefulExit() -} - -void connectDB(main) diff --git a/src/db/import/json/request-handler.ts b/src/db/import/json/request-handler.ts deleted file mode 100644 index 98d45f68..00000000 --- a/src/db/import/json/request-handler.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { logger } from '../../../logger.js' -import { bulkImportJson } from './import-json.js' - -export const importJsonRequestHandler = async (req, res): Promise => { - try { - const result = await bulkImportJson({ json: req.body, user: req.userId }) - if (result.errors.length > 0) { - logger.error(`Error importing JSON: ${result.errors.map(e => e.toString()).join(', ')}`) - res.status(500).send({ message: 'Error importing JSON', errors: result.errors.map((e) => e.toString()) }) - } else { - res.status(200).send(result) - logger.info(`Imported JSON: ${result.addedAreaIds.length} areas created, ${result.updatedAreaIds.length} areas updated, ${result.climbIds.length} climbs updated or added`) - } - } catch (e) { - logger.error(`Error importing JSON: ${e.toString() as string}`) - res.status(500).send(`Error importing JSON: ${e.toString() as string}`) - } -} diff --git a/src/graphql/area/AreaMutations.ts b/src/graphql/area/AreaMutations.ts index 6c62de5a..5790ec06 100644 --- a/src/graphql/area/AreaMutations.ts +++ b/src/graphql/area/AreaMutations.ts @@ -1,15 +1,16 @@ import muuid from 'uuid-mongodb' -import { AreaType } from '../../db/AreaTypes.js' -import { ContextWithAuth } from '../../types.js' +import {AreaType} from '../../db/AreaTypes.js' +import {ContextWithAuth} from '../../types.js' import type MutableAreaDataSource from '../../model/MutableAreaDataSource.js' +import {BulkImportInputType, BulkImportResultType} from "../../db/BulkImportTypes.js"; const AreaMutations = { - setDestinationFlag: async (_, { input }, context: ContextWithAuth): Promise => { - const { dataSources, user } = context - const { areas }: { areas: MutableAreaDataSource } = dataSources - const { id, flag } = input + setDestinationFlag: async (_, {input}, context: ContextWithAuth): Promise => { + const {dataSources, user} = context + const {areas}: { areas: MutableAreaDataSource } = dataSources + const {id, flag} = input // permission middleware shouldn't send undefined uuid if (user?.uuid == null) throw new Error('Missing user uuid') @@ -17,9 +18,9 @@ const AreaMutations = { return await areas.setDestinationFlag(user.uuid, muuid.from(id), flag) }, - removeArea: async (_, { input }, { dataSources, user }: ContextWithAuth): Promise => { - const { areas } = dataSources - const { uuid } = input + removeArea: async (_, {input}, {dataSources, user}: ContextWithAuth): Promise => { + const {areas} = dataSources + const {uuid} = input // permission middleware shouldn't send undefined uuid if (user?.uuid == null) throw new Error('Missing user uuid') @@ -27,9 +28,9 @@ const AreaMutations = { return await areas.deleteArea(user.uuid, muuid.from(uuid)) }, - addArea: async (_, { input }, { dataSources, user }: ContextWithAuth): Promise => { - const { areas } = dataSources - const { name, parentUuid, countryCode, experimentalAuthor, isLeaf, isBoulder } = input + addArea: async (_, {input}, {dataSources, user}: ContextWithAuth): Promise => { + const {areas} = dataSources + const {name, parentUuid, countryCode, experimentalAuthor, isLeaf, isBoulder} = input // permission middleware shouldn't send undefined uuid if (user?.uuid == null) throw new Error('Missing user uuid') @@ -44,13 +45,13 @@ const AreaMutations = { ) }, - updateArea: async (_, { input }, { dataSources, user }: ContextWithAuth): Promise => { - const { areas } = dataSources + updateArea: async (_, {input}, {dataSources, user}: ContextWithAuth): Promise => { + const {areas} = dataSources if (user?.uuid == null) throw new Error('Missing user uuid') if (input?.uuid == null) throw new Error('Missing area uuid') - const { lat, lng } = input + const {lat, lng} = input if (lat != null && !isLatitude(lat)) throw Error('Invalid latitude') if (lng != null && !isLongitude(lng)) throw Error('Invalid longitude') if ((lat == null && lng != null) || (lat != null && lng == null)) throw Error('Must provide both latitude and longitude') @@ -69,14 +70,27 @@ const AreaMutations = { ) }, - updateAreasSortingOrder: async (_, { input }, { dataSources, user }: ContextWithAuth): Promise => { - const { areas } = dataSources + updateAreasSortingOrder: async (_, {input}, {dataSources, user}: ContextWithAuth): Promise => { + const {areas} = dataSources if (user?.uuid == null) throw new Error('Missing user uuid') return await areas.updateSortingOrder( user.uuid, input ) + }, + + bulkImportAreas: async (_, {input}: { input: BulkImportInputType }, { + dataSources, + user + }: ContextWithAuth): Promise => { + const {bulkImport, climbs} = dataSources + if (user?.uuid == null) throw new Error('Missing user uuid') + return await bulkImport.bulkImport({ + user: user.uuid, + input, + climbs + }) } } diff --git a/src/graphql/schema/AreaEdit.gql b/src/graphql/schema/AreaEdit.gql index 3db833d1..91718ba1 100644 --- a/src/graphql/schema/AreaEdit.gql +++ b/src/graphql/schema/AreaEdit.gql @@ -23,6 +23,13 @@ type Mutation { Update area sorting order in bulk """ updateAreasSortingOrder(input: [AreaSortingInput]): [ID] + + """ + Add or update an area tree in bulk, including climbs and pitches. + You can start at any point in the tree given a valid parent area with its id. + If starting at the root level, the countryCode must be provided. + """ + bulkImportAreas(input: BulkImportInput): [BulkImportResult] } input DestinationFlagInput { @@ -44,6 +51,117 @@ input AreaInput { experimentalAuthor: ExperimentalAuthorType } +""" +Bulk input for adding or updating areas, climbs, and pitches. +""" +input BulkImportInput { + areas: [BulkImportAreaInput]! +} + +""" +Bulk input for adding or updating areas. +Either define `id` or `name` to indicate whether to add or update an area. +Provide an `id` to update an area, and `name` to add a new area. +If none of the fields are provided, an error will be thrown. +""" +input BulkImportAreaInput { + "The area UUID, not the internal ID" + uuid: ID + "The name of the new area or if provided with an id, the new name of the area" + areaName: String + "An optional description of the area. If provided with an id, the description will be updated" + description: String + "Only relevant for the top most areas of a country. Get a list of valid countries by querying the countries field" + countryCode: String + "The grade context of the area. Every area that has climbs must have a valid grade context" + gradeContext: String + "The sorting index of the area. Defaults to -1 if not provided" + leftRightIndex: Int + "Coordinates of the area. If provided with an id, the coordinates will be updated" + lng: Float + "Coordinates of the area. If provided with an id, the coordinates will be updated" + lat: Float + "An optional bounding box that can be displayed on maps" + bbox: [Float] + "A list of child areas. They can be deeply nested" + children: [BulkImportAreaInput] + """ + A list of climbs that are directly associated with this area. + An area that has climbs cannot have children and is automatically a leaf node. + """ + climbs: [BulkImportClimbInput] +} + +""" +Bulk input for adding or updating climbs and pitches within an area. +Either define `id` or `name` to indicate whether to add or update a climb. +Provide an `id` to update a climb, and `name` to add a new climb. +If none of the fields are provided, an error will be thrown. +Make sure to update all climbs if the leftRightIndex of a climb is updated. +""" +input BulkImportClimbInput { + "The climb UUID, not the internal ID" + uuid: ID + "The name of the new climb or if provided with an id, the new name of the climb" + name: String + "The grade of the climb. The parent must have a valid grade context for this to be resolved correctly." + grade: String! + "The disciplines of the climb. At least one must be provided" + disciplines: DisciplineType! + "The safety rating of the climb" + safety: SafetyEnum + "Optional coordinates of the climb" + lng: Float + "Optional coordinates of the climb" + lat: Float + "The index of the climb in the area" + leftRightIndex: Int + "The description of the climb" + description: String + "The location of the climb, e.g. 'The first climb on the left, entry directly behind the tree'" + location: String + "The protection of the climb, e.g. 'Long run out to the first bolt'" + protection: String + "The legacy FA data" + fa: String + "The length of the climb in meters" + length: Int + "The number of fixed anchors" + boltsCount: Int + "The experimental author of the climb" + experimentalAuthor: ExperimentalAuthorType + "A list of pitches that are directly associated with this climb" + pitches: [BulkImportPitchesInput] +} + +""" +Bulk input for adding or updating pitches within a climb. +Define `id` to update a pitch. +Make sure to update all pitches if the pitchNumber of a pitch is changed. +""" +input BulkImportPitchesInput { + "The pitch UUID, if provided the pitch will be updated" + id: ID + "The index of the pitch in the climb" + pitchNumber: Int! + "The grade of the pitch." + grade: String! + "The disciplines of the pitch if different from the parent climb" + disciplines: DisciplineType + "The description of the pitch" + description: String + "The length of the pitch in meters" + length: Int + "The number of fixed anchors" + boltsCount: Int +} + +type BulkImportResult { + addedAreas: [Area] + updatedAreas: [Area] + addedOrUpdatedClimbs: [Climb] +} + input RemoveAreaInput { uuid: String! } diff --git a/src/model/BulkImportDataSource.ts b/src/model/BulkImportDataSource.ts new file mode 100644 index 00000000..4d36f7c1 --- /dev/null +++ b/src/model/BulkImportDataSource.ts @@ -0,0 +1,181 @@ +import MutableAreaDataSource from "./MutableAreaDataSource.js"; +import mongoose from "mongoose"; +import {withTransaction} from "../utils/helpers.js"; +import muuid, {MUUID} from "uuid-mongodb"; +import {AreaType} from "../db/AreaTypes.js"; +import { + BulkImportAreaInputType, + BulkImportClimbInputType, + BulkImportInputType, + BulkImportResultType +} from "../db/BulkImportTypes.js"; +import MutableClimbDataSource from "./MutableClimbDataSource.js"; +import {logger} from "../logger.js"; +import {ClimbChangeInputType, ClimbType} from "../db/ClimbTypes.js"; + +export interface BulkImportOptions { + user: MUUID + input: BulkImportInputType + session?: mongoose.ClientSession + climbs?: MutableClimbDataSource +} + +export default class BulkImportDataSource extends MutableAreaDataSource { + + /** + * + * @param json the json to import formatted in a valid database format + * @returns a list of ids of the areas that were imported + */ + async bulkImport({ + user, + input, + session: _session, + climbs = MutableClimbDataSource.getInstance() + }: BulkImportOptions): Promise { + const result: BulkImportResultType = { + addedAreas: [], + updatedAreas: [], + addedOrUpdatedClimbs: [], + } + logger.debug('starting bulk import session...') + const session = _session ?? (await mongoose.startSession()) + try { + return await withTransaction(session, async () => { + logger.info('starting bulk import...', input) + return await this._bulkImportJson({user, input, climbs, session}) + }) ?? result + } catch (e) { + logger.error('bulk import failed', e) + throw e + } finally { + await session.endSession() + } + } + + private async _bulkImportJson({ + user, + input, + session, + climbs = MutableClimbDataSource.getInstance() + }: BulkImportOptions): Promise { + const addOrUpdateArea = async ( + areaNode: BulkImportAreaInputType, + parentUuid?: MUUID + ): Promise => { + const result: BulkImportResultType = { + addedAreas: [], + updatedAreas: [], + addedOrUpdatedClimbs: [] + } + let area: AreaType | null + if (areaNode.uuid !== undefined && areaNode.uuid !== null) { + area = await this.updateAreaWith({ + user, areaUuid: areaNode.uuid, document: { + areaName: areaNode.areaName, + description: areaNode.description, + leftRightIndex: areaNode.leftRightIndex, + lng: areaNode.lng, + lat: areaNode.lat, + }, session + }) + if (area != null) { + result.updatedAreas.push(area) + } else { + throw new Error(`area with id ${areaNode.uuid} (${areaNode.areaName ?? 'unknown name'}) not found`) + } + } else if (areaNode.areaName !== undefined) { + area = await this.addAreaWith({ + user, + areaName: areaNode.areaName, + countryCode: areaNode.countryCode, + parentUuid, + session + }).then((area) => { + return this.updateArea(user, area.metadata.area_id, { + description: areaNode.description, + leftRightIndex: areaNode.leftRightIndex, + lng: areaNode.lng, + lat: areaNode.lat + }, session) + }) + if (area != null) { + result.addedAreas.push(area) + } else { + throw new Error(`failed to add area ${areaNode.areaName} to parent ${parentUuid}`) + } + } else { + throw new Error('areaName or id is required') + } + if (areaNode.children !== undefined) { + for (const child of areaNode.children) { + const childResult = await addOrUpdateArea(child, area.metadata.area_id) + result.updatedAreas.push(...childResult.updatedAreas) + result.addedAreas.push(...childResult.addedAreas) + result.addedOrUpdatedClimbs.push(...childResult.addedOrUpdatedClimbs) + } + } + if (areaNode.climbs !== undefined) { + result.addedOrUpdatedClimbs.push(...(await climbs?.addOrUpdateClimbsWith({ + userId: user, + parentId: area.metadata.area_id, + changes: [...areaNode.climbs.map(this.toClimbChangeInputType) ?? []], + session + }).then((climbIds) => climbIds.map((id) => + climbs?.findOneClimbByMUUID(muuid.from(id))) + .filter((climb) => climb !== null) + .map((climb) => climb as unknown as ClimbType)) + )) + } + return result + } + + const results = await Promise.all( + input?.areas.map((area) => addOrUpdateArea(area)) ?? [] + ) + return results.reduce((acc, result) => { + acc.addedAreas.push(...result.addedAreas) + acc.updatedAreas.push(...result.updatedAreas) + acc.addedOrUpdatedClimbs.push(...result.addedOrUpdatedClimbs) + return acc + }, { + addedAreas: [], + updatedAreas: [], + addedOrUpdatedClimbs: [] + }) + } + + private toClimbChangeInputType(climb: BulkImportClimbInputType): ClimbChangeInputType { + return { + id: climb.uuid?.toUUID().toString(), + name: climb.name, + grade: climb.grade, + disciplines: climb.disciplines, + leftRightIndex: climb.leftRightIndex, + description: climb.description, + location: climb.location, + protection: climb.protection, + fa: climb.fa, + length: climb.length, + boltsCount: climb.boltsCount, + experimentalAuthor: climb.experimentalAuthor, + pitches: climb.pitches?.map((pitch) => ({ + pitchNumber: pitch.pitchNumber, + grade: pitch.grade, + disciplines: pitch.disciplines, + description: pitch.description, + length: pitch.length, + boltsCount: pitch.boltsCount + })) + } + } + + static instance: BulkImportDataSource + + static getInstance(): BulkImportDataSource { + if (BulkImportDataSource.instance == null) { + BulkImportDataSource.instance = new BulkImportDataSource(mongoose.connection.db.collection('areas')) + } + return BulkImportDataSource.instance + } +} \ No newline at end of file diff --git a/src/db/import/json/__tests__/import-json.test.ts b/src/model/__tests__/BulkDataSource.test.ts similarity index 69% rename from src/db/import/json/__tests__/import-json.test.ts rename to src/model/__tests__/BulkDataSource.test.ts index 0b03f80e..9fd83866 100644 --- a/src/db/import/json/__tests__/import-json.test.ts +++ b/src/model/__tests__/BulkDataSource.test.ts @@ -1,61 +1,46 @@ import {ChangeStream} from 'mongodb'; import muuid from 'uuid-mongodb'; -import {changelogDataSource} from '../../../../model/ChangeLogDataSource.js'; -import MutableAreaDataSource from '../../../../model/MutableAreaDataSource.js'; -import MutableClimbDataSource from '../../../../model/MutableClimbDataSource.js'; -import {AreaType} from '../../../AreaTypes.js'; -import {ClimbType} from '../../../ClimbTypes.js'; -import streamListener from '../../../edit/streamListener.js'; -import {AreaJson, bulkImportJson, BulkImportResult} from '../import-json.js'; -import inMemoryDB from "../../../../utils/inMemoryDB.js"; -import {isFulfilled, isRejected} from "../../../../utils/testUtils.js"; - -type TestResult = BulkImportResult & { - addedAreas: Partial[]; - updatedAreas: Partial[]; - addedClimbs: Partial[]; -}; +import {changelogDataSource} from '../ChangeLogDataSource.js'; +import MutableClimbDataSource from '../MutableClimbDataSource.js'; +import {AreaType} from '../../db/AreaTypes.js'; +import {ClimbType} from '../../db/ClimbTypes.js'; +import streamListener from '../../db/edit/streamListener.js'; +import inMemoryDB from "../../utils/inMemoryDB.js"; +import {isFulfilled} from "../../utils/testUtils.js"; +import BulkImportDataSource from "../BulkImportDataSource.js"; +import {BulkImportAreaInputType, BulkImportResultType} from "../../db/BulkImportTypes.js"; describe('bulk import e2e', () => { - let areas: MutableAreaDataSource; + let bulkImport: BulkImportDataSource; let climbs: MutableClimbDataSource; let stream: ChangeStream; const testUser = muuid.v4(); - const assertBulkImport = async (...json: AreaJson[]): Promise => { - const result = await bulkImportJson({ + const assertBulkImport = async (...input: BulkImportAreaInputType[]): Promise => { + const result = await bulkImport.bulkImport({ user: testUser, - json: {areas: json}, - areas, + input: {areas: input}, + climbs }); const addedAreas = await Promise.allSettled( - result.addedAreaIds.map((areaId) => - areas.findOneAreaByUUID(muuid.from(areaId)) + result.addedAreas.map((area) => + bulkImport.findOneAreaByUUID(area.metadata.area_id) ) ); const updatedAreas = await Promise.allSettled( - result.updatedAreaIds.map((areaId) => - areas.findOneAreaByUUID(muuid.from(areaId)) + result.updatedAreas.map((area) => + bulkImport.findOneAreaByUUID(area.metadata.area_id) ) ); - const committedClimbs = await Promise.allSettled( - result.climbIds.map((id) => climbs.findOneClimbByMUUID(muuid.from(id))) + const addedOrUpdatedClimbs = await Promise.allSettled( + result.addedOrUpdatedClimbs.map((climb) => climbs.findOneClimbByMUUID(climb._id)) ); return { - ...result, - errors: [ - ...result.errors, - ...addedAreas.filter(isRejected).map((p) => p.reason), - ...committedClimbs.filter(isRejected).map((p) => p.reason), - ...updatedAreas.filter(isRejected).map((p) => p.reason), - ], addedAreas: addedAreas.filter(isFulfilled).map((p) => p.value), updatedAreas: updatedAreas.filter(isFulfilled).map((p) => p.value), - addedClimbs: committedClimbs - .filter(isFulfilled) - .map((p) => p.value as Partial), + addedOrUpdatedClimbs: addedOrUpdatedClimbs.filter(isFulfilled).map((p) => p.value as ClimbType), }; }; @@ -74,10 +59,10 @@ describe('bulk import e2e', () => { }); beforeEach(async () => { - areas = MutableAreaDataSource.getInstance(); + bulkImport = BulkImportDataSource.getInstance(); climbs = MutableClimbDataSource.getInstance(); - await areas.addCountry('us'); + await bulkImport.addCountry('us'); }); afterEach(async () => { @@ -93,7 +78,6 @@ describe('bulk import e2e', () => { countryCode: 'us', }) ).resolves.toMatchObject({ - errors: [], addedAreas: [ { area_name: 'Minimal Area', @@ -118,10 +102,7 @@ describe('bulk import e2e', () => { areaName: 'Test Area 2', } ) - ).resolves.toMatchObject({ - errors: expect.arrayContaining([expect.any(Error)]), - addedAreas: [], - }); + ).rejects.toThrowError("Must provide parent Id or country code"); }); it('should import nested areas with children', async () => { @@ -136,7 +117,6 @@ describe('bulk import e2e', () => { ], }) ).resolves.toMatchObject({ - errors: [], addedAreas: [ {area_name: 'Parent Area', gradeContext: 'US'}, {area_name: 'Child Area 2', gradeContext: 'US'}, @@ -161,7 +141,6 @@ describe('bulk import e2e', () => { ], }) ).resolves.toMatchObject({ - errors: [], addedAreas: [ { area_name: 'Test Area', @@ -202,7 +181,6 @@ describe('bulk import e2e', () => { ], }) ).resolves.toMatchObject({ - errors: [], addedAreas: [ { area_name: 'Test Area', @@ -211,10 +189,15 @@ describe('bulk import e2e', () => { leaf: true, isBoulder: false, }, - climbs: [expect.anything()], + climbs: [{ + name: 'Test Climb', + grades: { + yds: '5.10a', + }, + }], }, ], - addedClimbs: [ + addedOrUpdatedClimbs: [ { name: 'Test Climb', grades: { @@ -239,11 +222,10 @@ describe('bulk import e2e', () => { it('should update an existing area', async () => { await expect( assertBulkImport({ - id: area.metadata.area_id, + uuid: area.metadata.area_id, areaName: 'New Name', }) ).resolves.toMatchObject({ - errors: [], updatedAreas: [{area_name: 'New Name'}], }); }); diff --git a/src/server.ts b/src/server.ts index e96bc43d..d8828908 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,30 +1,27 @@ -import { ApolloServer } from 'apollo-server-express' +import {ApolloServer} from 'apollo-server-express' import mongoose from 'mongoose' -import { applyMiddleware } from 'graphql-middleware' -import { graphqlSchema } from './graphql/resolvers.js' +import {applyMiddleware} from 'graphql-middleware' +import {graphqlSchema} from './graphql/resolvers.js' import MutableAreaDataSource from './model/MutableAreaDataSource.js' import ChangeLogDataSource from './model/ChangeLogDataSource.js' import MutableMediaDataSource from './model/MutableMediaDataSource.js' import MutableClimbDataSource from './model/MutableClimbDataSource.js' import TickDataSource from './model/TickDataSource.js' -import { authMiddleware, createContext } from './auth/middleware.js' +import {createContext} from './auth/middleware.js' import permissions from './auth/permissions.js' -import { localDevBypassAuthContext, localDevBypassAuthMiddleware } from './auth/local-dev/middleware.js' +import {localDevBypassAuthContext} from './auth/local-dev/middleware.js' import localDevBypassAuthPermissions from './auth/local-dev/permissions.js' import XMediaDataSource from './model/XMediaDataSource.js' import PostDataSource from './model/PostDataSource.js' import MutableOrgDS from './model/MutableOrganizationDataSource.js' -import type { Context } from './types.js' -import type { DataSources } from 'apollo-server-core/dist/graphqlOptions' +import type {Context} from './types.js' +import type {DataSources} from 'apollo-server-core/dist/graphqlOptions' import UserDataSource from './model/UserDataSource.js' import express from 'express' import * as http from 'http' -import bodyParser from 'body-parser' -import { importJsonRequestHandler } from './db/import/json/request-handler.js' -import { hasEditorRoleMiddleware } from './auth/rules' -export async function createServer (): Promise<{ app: express.Application, server: ApolloServer }> { +export async function createServer(): Promise<{ app: express.Application, server: ApolloServer }> { const schema = applyMiddleware( graphqlSchema, (process.env.LOCAL_DEV_BYPASS_AUTH === 'true' ? localDevBypassAuthPermissions : permissions).generate(graphqlSchema) @@ -56,19 +53,12 @@ export async function createServer (): Promise<{ app: express.Application, serve }) // server must be started before applying middleware await server.start() + server.applyMiddleware({app, path: '/'}) - app.post('/import', [ - process.env.LOCAL_DEV_BYPASS_AUTH === 'true' ? localDevBypassAuthMiddleware : authMiddleware, - hasEditorRoleMiddleware, - bodyParser.json(), - importJsonRequestHandler - ]) - server.applyMiddleware({ app, path: '/' }) - - return { app, server } + return {app, server} } -export async function startServer ({ app, server, port = 4000 }: { +export async function startServer({app, server, port = 4000}: { app: express.Application server: ApolloServer port?: number @@ -80,6 +70,6 @@ export async function startServer ({ app, server, port = 4000 }: { throw e }) - await new Promise((resolve) => httpServer.listen({ port }, resolve)) + await new Promise((resolve) => httpServer.listen({port}, resolve)) console.log(`🚀 Server ready at http://localhost:${port}${server.graphqlPath}`) } diff --git a/src/types.ts b/src/types.ts index 79bc66b8..8a60513f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ -import { BBox } from '@turf/helpers' -import { MUUID } from 'uuid-mongodb' +import {BBox} from '@turf/helpers' +import {MUUID} from 'uuid-mongodb' -import { AreaType } from './db/AreaTypes.js' +import {AreaType} from './db/AreaTypes.js' import type MutableAreaDataSource from './model/MutableAreaDataSource.js' import type TickDataSource from './model/TickDataSource.js' import type HistoryDataSouce from './model/ChangeLogDataSource.js' @@ -11,6 +11,7 @@ import XMediaDataSource from './model/XMediaDataSource.js' import PostDataSource from './model/PostDataSource.js' import MutableOrganizationDataSource from './model/MutableOrganizationDataSource.js' import type UserDataSource from './model/UserDataSource.js' +import BulkImportDataSource from "./model/BulkImportDataSource"; export enum SortDirection { ASC = 1, @@ -67,6 +68,7 @@ export type OrganizationGQLFilter = Partial => { + query, + operationName, + variables, + userUuid = '', + roles = [], + app, + endpoint = '/', + port = PORT, + }: QueryAPIProps): Promise => { // Avoid needing to pass in actual signed tokens. const jwtSpy = jest.spyOn(jwt, 'verify') jwtSpy.mockImplementation(() => { @@ -46,10 +45,11 @@ export const queryAPI = async ({ } }) - return await request(app ?? `http://localhost:${port}`) - .post(endpoint) - .send(body) - .set('Authorization', 'Bearer placeholder-jwt-see-SpyOn') + const queryObj = {query, operationName, variables} + return request(app ?? `http://localhost:${port}`) + .post(endpoint) + .send(queryObj) + .set('Authorization', 'Bearer placeholder-jwt-see-SpyOn') } export interface SetUpServerReturnType { @@ -63,8 +63,8 @@ export interface SetUpServerReturnType { */ export const setUpServer = async (): Promise => { await inMemoryDB.connect() - const { app, server } = await createServer() - return { app, server, inMemoryDB } + const {app, server} = await createServer() + return {app, server, inMemoryDB} } export const isFulfilled = (