├── apollo.config.js ├── .babelrc ├── .prettierrc.js ├── templates └── reset_password.hbs ├── models ├── models.ts ├── Token.ts ├── Notification.ts ├── Roster.ts ├── TroopAndPatrol.ts ├── User.ts └── Event.ts ├── .gcloudignore ├── src ├── utils │ ├── mongoose.ts │ └── Auth.ts ├── middleware │ └── typegoose_middlware.ts ├── index.ts ├── services │ └── email.ts ├── routes │ └── upload.ts ├── server.ts ├── context.ts ├── resolvers │ ├── auth.ts │ ├── troop.ts │ ├── patrol.ts │ ├── user.ts │ └── event.ts ├── notifications.ts └── Event │ └── EventSchemas.json ├── .vscode └── launch.json ├── LICENSE ├── tsconfig.json ├── tests ├── utils │ ├── test_context.ts │ └── test_setup.ts └── resolvers │ ├── auth.test.ts │ └── user.test.ts ├── README.md ├── .gitignore └── package.json /apollo.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | service: { 3 | name: "scouttrek" 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["graphql-tag"], 4 | "env": { 5 | "debug": { 6 | "sourceMap": "inline", 7 | "retainLines": true 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | tabWidth: 2, 4 | singleQuote: true, 5 | trailingComma: 'es5', 6 | importOrderSeparation: true, 7 | importOrderSortSpecifiers: true, 8 | arrowParens: 'avoid', 9 | }; 10 | -------------------------------------------------------------------------------- /templates/reset_password.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | Reset Password 4 | 5 | 6 |
7 |

Here's the token to reset your ScoutTrek password:

8 |

{{token}}

9 |

If you did not make a request to reset your password, please ignore this email.

10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /models/models.ts: -------------------------------------------------------------------------------- 1 | import { getModelForClass } from "@typegoose/typegoose"; 2 | import { User } from "./User"; 3 | import { Patrol, Troop } from "./TroopAndPatrol"; 4 | import { Event } from "./Event"; 5 | import { Token } from "./Token"; 6 | 7 | export const UserModel = getModelForClass(User); 8 | export const PatrolModel = getModelForClass(Patrol); 9 | export const TroopModel = getModelForClass(Troop); 10 | export const EventModel = getModelForClass(Event); 11 | export const TokenModel = getModelForClass(Token); -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud Platform 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | # Node.js dependencies: 17 | node_modules/ -------------------------------------------------------------------------------- /src/utils/mongoose.ts: -------------------------------------------------------------------------------- 1 | import { isDocument, Ref } from "@typegoose/typegoose"; 2 | import mongoose from "mongoose"; 3 | 4 | // Converts a Ref to a Document if it is populated, throws an error otherwise 5 | export function getDocument(ref: Ref): T { 6 | if (isDocument(ref)) { 7 | return ref; 8 | } else { 9 | throw new Error("Docment not populated!"); 10 | } 11 | } 12 | 13 | // Converts Refs to a Documents if they are all populated, throws an error otherwise 14 | export function getDocuments(refs: Ref[]): T[] { 15 | return refs.map(getDocument); 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}/src/index.js", 12 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/babel-node", 13 | "runtimeArgs": ["--nolazy", "-r", "dotenv/config"], 14 | "env": { 15 | "BABEL_ENV": "debug" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /models/Token.ts: -------------------------------------------------------------------------------- 1 | import { modelOptions, prop as Property } from '@typegoose/typegoose'; 2 | import mongoose from 'mongoose'; 3 | 4 | import { User } from './User'; 5 | 6 | import type { Ref } from "@typegoose/typegoose"; 7 | 8 | export enum TOKEN_TYPE { 9 | SESSION, 10 | PASS_RESET, 11 | } 12 | 13 | @modelOptions({ 14 | schemaOptions: { 15 | timestamps: true, 16 | } 17 | }) 18 | export class Token { 19 | @Property({ required: true, enum: TOKEN_TYPE }) 20 | public type!: TOKEN_TYPE; 21 | 22 | @Property({ required: true, ref: () => User }) 23 | public user!: Ref; 24 | 25 | @Property({ required: true }) 26 | public token!: string; 27 | 28 | @Property({ expires: 3600 }) 29 | public createdAt?: Date; 30 | } 31 | -------------------------------------------------------------------------------- /src/middleware/typegoose_middlware.ts: -------------------------------------------------------------------------------- 1 | import { getClass } from '@typegoose/typegoose'; 2 | import { Document } from 'mongoose'; 3 | import { MiddlewareFn } from 'type-graphql'; 4 | 5 | export const TypegooseMiddleware: MiddlewareFn = async (_, next) => { 6 | const result = await next(); 7 | 8 | if (Array.isArray(result)) { 9 | return result.map(item => (item instanceof Document ? convertDocument(item) : item)); 10 | } 11 | 12 | if (result instanceof Document) { 13 | return convertDocument(result); 14 | } 15 | 16 | return result; 17 | }; 18 | 19 | function convertDocument(doc: Document) { 20 | const convertedDocument = doc.toObject(); 21 | const DocumentClass = getClass(doc)!; 22 | Object.setPrototypeOf(convertedDocument, DocumentClass.prototype); 23 | return convertedDocument; 24 | } -------------------------------------------------------------------------------- /models/Notification.ts: -------------------------------------------------------------------------------- 1 | import { modelOptions, prop as Property } from '@typegoose/typegoose'; 2 | import mongoose from 'mongoose'; 3 | import { Field, ID, ObjectType } from 'type-graphql'; 4 | 5 | @modelOptions({ 6 | schemaOptions: { 7 | toJSON: { virtuals: true }, 8 | toObject: { virtuals: true }, 9 | timestamps: true 10 | } 11 | }) 12 | @ObjectType() 13 | export class Notification { 14 | @Field(type => ID, {name: "id"}) 15 | readonly _id: mongoose.Types.ObjectId; 16 | 17 | @Field() 18 | @Property() 19 | public title!: string; 20 | 21 | @Field() 22 | @Property() 23 | public type!: string; 24 | 25 | @Field() 26 | @Property() 27 | public eventType!: string; 28 | 29 | @Field() 30 | @Property() 31 | public eventID!: string; 32 | 33 | @Field({nullable: true}) 34 | createdAt?: Date; 35 | 36 | @Field({nullable: true}) 37 | updatedAt?: Date; 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Content and Design Copyright (c) ScoutTrek, LLC. All rights reserved. 2 | 3 | Code Copyright (c) ScoutTrek, LLC and licensed under the following conditions: 4 | 5 | Permission is hereby restricted, to any person not associated with ScoutTrek, LLC, in obtaining a copy 6 | of this software and associated documentation files (the "Software"). 7 | 8 | The above copyright notice and this permission notice shall be included in 9 | all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 17 | THE SOFTWARE. -------------------------------------------------------------------------------- /models/Roster.ts: -------------------------------------------------------------------------------- 1 | import { modelOptions, prop as Property } from '@typegoose/typegoose'; 2 | import mongoose from 'mongoose'; 3 | import { Field, ObjectType } from 'type-graphql'; 4 | 5 | import { Event } from './Event'; 6 | import { User } from './User'; 7 | 8 | import type { Ref } from '@typegoose/typegoose'; 9 | 10 | @modelOptions({ 11 | schemaOptions: { 12 | timestamps: true 13 | } 14 | }) 15 | @ObjectType() 16 | export class Roster { 17 | @Property({ required: true, ref: () => Event}) 18 | public eventId!: Ref; 19 | 20 | @Field(type => [User]) 21 | @Property({ required: true, ref: () => User }) 22 | public yes!: Ref[]; 23 | 24 | @Field(type => [User]) 25 | @Property({ required: true, ref: () => User }) 26 | public no!: Ref[]; 27 | 28 | @Field(type => [User]) 29 | @Property({ required: true, ref: () => User }) 30 | public maybe!: Ref[]; 31 | } 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { expressMiddleware } from '@apollo/server/express4'; 2 | import { json } from 'body-parser'; 3 | import cors from 'cors'; 4 | import express from 'express'; 5 | 6 | import contextFn from './context'; 7 | import { uploadPhotoRoute } from './routes/upload'; 8 | import apolloServer from './server'; 9 | 10 | async function startServer() { 11 | let server = await apolloServer; 12 | if (server === undefined) { 13 | return; 14 | } 15 | await server.start(); 16 | 17 | const app = express(); 18 | app.use( 19 | '/graphql', 20 | cors(), 21 | json(), 22 | expressMiddleware(server, { 23 | context: contextFn, 24 | }) 25 | ); 26 | 27 | const port = process.env.PORT || 4000; 28 | 29 | uploadPhotoRoute(app); 30 | 31 | await new Promise((resolve) => { 32 | const serverResponse = app.listen(port); 33 | resolve(serverResponse); 34 | }); 35 | console.log(`🚀 Server ready at http://localhost:${port}/graphql`); 36 | } 37 | 38 | startServer(); 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "emitDecoratorMetadata": true, 5 | "target": "ES6", 6 | // "module": "CommonJS", 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "allowSyntheticDefaultImports": true, 10 | "moduleResolution": "node", 11 | "strict": false, 12 | "strictNullChecks": true, 13 | "noImplicitAny": true, 14 | "skipLibCheck": true, 15 | "noUncheckedIndexedAccess": true, 16 | "baseUrl": "./", 17 | "typeRoots": ["node_modules/@types"], 18 | "outDir": "dist", 19 | "resolveJsonModule": true, 20 | 21 | // Ensure that .d.ts files are created by tsc, but not .js files 22 | "declaration": true, 23 | "emitDeclarationOnly": true, 24 | // Ensure that Babel can safely transpile files in the TypeScript project 25 | "isolatedModules": true 26 | 27 | // "strictPropertyInitialization": true, 28 | // "noImplicitThis": true 29 | }, 30 | // "exclude": ["node_modules"] 31 | "include": ["src/**/*", "models/**/*", "tests/**/*"] 32 | } 33 | -------------------------------------------------------------------------------- /src/services/email.ts: -------------------------------------------------------------------------------- 1 | import handlebars from 'handlebars'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import sgMail from '@sendgrid/mail'; 5 | 6 | const templatesFolder = path.join(__dirname, '..', '..', 'templates'); 7 | 8 | export async function sendResetPasswordEmail(email: string, token: string): Promise { 9 | const emailTemplateSource = fs.readFileSync(path.join(templatesFolder, 'reset_password.hbs'), "utf8"); 10 | 11 | sgMail.setApiKey(process.env.SENDGRID_API_KEY!); 12 | 13 | const template = handlebars.compile(emailTemplateSource); 14 | const htmlToSend = template({token}) 15 | 16 | const msg = { 17 | from: { 18 | email: "info@scouttrek.com", 19 | name: "ScoutTrek", 20 | }, 21 | to: email, 22 | subject: "ScoutTrek Password Reset", 23 | html: htmlToSend 24 | }; 25 | 26 | try { 27 | await sgMail.send(msg); 28 | return true; 29 | } catch (err) { 30 | console.error(err); 31 | return false; 32 | } 33 | } -------------------------------------------------------------------------------- /tests/utils/test_context.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | import { EventModel, TroopModel, UserModel, TokenModel } from '../../models/models'; 4 | import { ContextType } from '../../src/context'; 5 | import * as authFns from '../../src/utils/Auth'; 6 | 7 | async function createTestContext(userID?: mongoose.Types.ObjectId, membershipIDString?: string): Promise { 8 | const ret: ContextType = { 9 | UserModel, 10 | EventModel, 11 | TroopModel, 12 | TokenModel, 13 | authFns, 14 | }; 15 | 16 | if (!userID) { 17 | return ret; 18 | } 19 | const user = await UserModel.findById(userID); 20 | if (!user) { 21 | return ret; 22 | } 23 | ret.user = user; 24 | if (!membershipIDString) { 25 | return ret; 26 | } 27 | const currMembership = user.groups.find((membership) => { 28 | return membership._id.equals(membershipIDString); 29 | }); 30 | if (!currMembership) { 31 | return ret; 32 | } 33 | ret.membershipIDString = membershipIDString; 34 | ret.currMembership = currMembership; 35 | return ret; 36 | }; 37 | 38 | export default createTestContext; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScoutTrek API 2 | 3 | ## Apollo GraphQL schema for Boy and Girls Scout Troops to create custom events 4 | 5 | * Create common events such as camping, hiking, and backpacking based on ScoutTrek templates 6 | * Create custom event templates for your Troop and allow authorized users like Patrol Leaders to create those event types 7 | * Store, update, and send notifcations based on the latest changes to events, ensuring all users are up to date 8 | 9 | 10 | ## Tech 11 | 12 | * Typescript 13 | * Apollo Server 14 | * Mongoose + Atlas MongoDB Database 15 | * Google Cloud App Engine 16 | 17 | Note: Important libraries include type-graphql and typegoose, which help with types for graphql and mongoose respectively 18 | 19 | 20 | ## Setting up on local 21 | 1. Clone the repo to your local using `https://github.com/sandboxnu/ScoutTrek-Backend.git` 22 | 2. Get the .env file (reach out to a ScoutTrek developer) and add it to the root directory 23 | 3. Reach out to a ScoutTrek developer for MongoDB and Google Cloud invite. Check notion for 24 | instructions on setting up Google Cloud. 25 | 4. In the terminal, run `yarn install` 26 | 5. Install [MongoDB](https://www.mongodb.com/docs/manual/installation/) 27 | 6. Run `yarn start` to start the server 28 | 7. Make sure you have `ScoutTrek-Frontend` set up as well -- see the [readme](https://github.com/sandboxnu/ScoutTrek-Frontend#readme) to begin developing 29 | -------------------------------------------------------------------------------- /tests/utils/test_setup.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | async function removeAllCollections () { 4 | const collections = Object.keys(mongoose.connection.collections) 5 | for (const collectionName of collections) { 6 | const collection = mongoose.connection.collections[collectionName] 7 | await collection?.deleteMany({}); 8 | } 9 | } 10 | 11 | async function dropAllCollections () { 12 | const collections = Object.keys(mongoose.connection.collections); 13 | for (const collectionName of collections) { 14 | const collection = mongoose.connection.collections[collectionName]; 15 | try { 16 | await collection?.drop(); 17 | } catch (error) { 18 | if (error.message === 'ns not found') return; 19 | if (error.message.includes('a background operation is currently running')) return; 20 | console.log(error.message); 21 | } 22 | } 23 | } 24 | 25 | export function setupDB (databaseName: string) { 26 | // Connect to Mongoose 27 | beforeAll(async () => { 28 | const url = `mongodb://127.0.0.1/${databaseName}`; 29 | await mongoose.connect(url); 30 | }); 31 | 32 | // Cleans up database between each test 33 | afterEach(async () => { 34 | await removeAllCollections(); 35 | }); 36 | 37 | // Disconnect Mongoose 38 | afterAll(async () => { 39 | await dropAllCollections(); 40 | await mongoose.connection.close(); 41 | }); 42 | } 43 | 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | .DS_Store 10 | 11 | # Google App Engine 12 | app.yml 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | 24 | # node-waf configuration 25 | .lock-wscript 26 | 27 | # Compiled binary addons (https://nodejs.org/api/addons.html) 28 | build/Release 29 | 30 | # Dependency directories 31 | node_modules/ 32 | jspm_packages/ 33 | 34 | # Snowpack dependency directory (https://snowpack.dev/) 35 | web_modules/ 36 | 37 | # TypeScript cache 38 | *.tsbuildinfo 39 | 40 | # Optional npm cache directory 41 | .npm 42 | 43 | # Optional eslint cache 44 | .eslintcache 45 | 46 | # Output of 'npm pack' 47 | *.tgz 48 | 49 | # Yarn Integrity file 50 | .yarn-integrity 51 | 52 | # dotenv environment variables file 53 | .env 54 | .env.test 55 | 56 | # parcel-bundler cache (https://parceljs.org/) 57 | .cache 58 | .parcel-cache 59 | 60 | # Next.js build output 61 | .next 62 | 63 | # Gatsby files 64 | .cache/ 65 | # Comment in the public line in if your project uses Gatsby and not Next.js 66 | # https://nextjs.org/blog/next-9-1#public-directory-support 67 | # public 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless/ 74 | 75 | # Stores VSCode versions used for testing VSCode extensions 76 | .vscode-test 77 | 78 | # yarn v2 79 | 80 | .yarn/cache 81 | .yarn/unplugged 82 | .yarn/build-state.yml 83 | .pnp.* 84 | 85 | app.yaml 86 | -------------------------------------------------------------------------------- /src/routes/upload.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from '@google-cloud/storage'; 2 | import multer from 'multer'; 3 | 4 | import { getTokenFromReq, getUserFromToken } from '../utils/Auth'; 5 | 6 | import type { Express } from 'express'; 7 | 8 | const storage = multer.memoryStorage(); 9 | const upload = multer({ storage, limits: { fileSize: 8 * 1024 * 1024 } }); 10 | 11 | const gcs = new Storage(); 12 | const bucketName = process.env.GCLOUD_STORAGE_BUCKET; 13 | const bucket = gcs.bucket(bucketName ?? ''); 14 | 15 | function getPublicUrl(filename: string): string { 16 | return 'https://storage.googleapis.com/' + bucketName + '/' + filename; 17 | } 18 | 19 | function getFileName(publicUrl: string): string | undefined { 20 | return publicUrl.split( 21 | 'https://storage.googleapis.com/' + bucketName + '/' 22 | )[1]; 23 | } 24 | export function uploadPhotoRoute(app: Express) { 25 | app.post('/upload', upload.single('photo'), async (req, res) => { 26 | const token = getTokenFromReq(req); 27 | if (!token) { 28 | return res.status(401).send({ message: 'Unauthorized' }); 29 | } 30 | const user = await getUserFromToken(token); 31 | if (!user) { 32 | return res.status(401).send({ message: 'Unauthorized' }); 33 | } 34 | if (!req.file) { 35 | return res.status(400).send({ message: 'Please upload a file!' }); 36 | } 37 | const filename = req.file.originalname; 38 | try { 39 | const newFile = bucket.file(filename); 40 | await new Promise((resolves, rejects) => { 41 | newFile 42 | .createWriteStream({ resumable: false }) 43 | .on('finish', resolves) 44 | .on('error', rejects) 45 | .end(req.file!.buffer); // Write the input buffer to the file and end 46 | }); 47 | const newPhoto = getPublicUrl(filename); 48 | const oldFile = getFileName(user.userPhoto); 49 | if (oldFile) bucket.file(oldFile).delete(); 50 | 51 | user.userPhoto = newPhoto; 52 | await user.save(); 53 | res.status(200).send({ url: newPhoto }); 54 | } catch (err) { 55 | return res.status(500).send({ 56 | message: `Could not upload the file: ${filename}. ${err}`, 57 | }); 58 | } 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from '@apollo/server'; 2 | import mongoose from 'mongoose'; 3 | import cron from 'node-cron'; 4 | import { buildSchema } from 'type-graphql'; 5 | 6 | import { EventModel } from '../models/models'; 7 | import { TypegooseMiddleware } from './middleware/typegoose_middlware'; 8 | import { getUserNotificationData, sendNotifications } from './notifications'; 9 | import { AuthResolver } from './resolvers/auth'; 10 | import { EventResolver, RosterResolver } from './resolvers/event'; 11 | import { PatrolResolver } from './resolvers/patrol'; 12 | import { TroopResolver } from './resolvers/troop'; 13 | import { UserResolver } from './resolvers/user'; 14 | import * as authFns from './utils/Auth'; 15 | 16 | // Models 17 | mongoose.connect(process.env.MONGO_URL!); 18 | 19 | const mongo = mongoose.connection; 20 | mongo.on("error", console.error.bind(console, "connection error:")); 21 | mongo.once("open", function () { 22 | console.log("Database connected!"); 23 | }); 24 | 25 | cron.schedule("* * * * *", async () => { 26 | const oneDayReminderEvents = await EventModel.find({ 27 | notification: { $lte: new Date() } 28 | }); 29 | if (oneDayReminderEvents.length > 0) { 30 | oneDayReminderEvents.map(async (event) => { 31 | const tokens = await getUserNotificationData(event.troop._id.toString()); 32 | sendNotifications( 33 | tokens, 34 | `Friendly ScoutTrek Reminder that ${event.title} happens tomorrow!`, 35 | { type: "event", eventType: event.type, ID: event.id } 36 | ); 37 | event.notification = undefined; 38 | event.save(); 39 | }); 40 | } 41 | }); 42 | 43 | async function bootstrap() { 44 | try { 45 | // build TypeGraphQL executable schema 46 | const schema = await buildSchema({ 47 | resolvers: [AuthResolver, EventResolver, UserResolver, TroopResolver, PatrolResolver, RosterResolver], 48 | globalMiddlewares: [TypegooseMiddleware], 49 | authChecker: authFns.customAuthChecker, 50 | }); 51 | 52 | // Create GraphQL server 53 | const server = new ApolloServer({ 54 | schema, 55 | }); 56 | 57 | return server; 58 | } catch (err) { 59 | console.error(err); 60 | } 61 | } 62 | 63 | const apolloServer = bootstrap(); 64 | 65 | export default apolloServer; 66 | -------------------------------------------------------------------------------- /src/utils/Auth.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { sign, verify } from 'jsonwebtoken'; 3 | import { AuthChecker, ResolverData } from 'type-graphql'; 4 | 5 | import { UserModel } from '../../models/models'; 6 | import { ROLE } from '../../models/TroopAndPatrol'; 7 | import { User } from '../../models/User'; 8 | 9 | import type { ContextType } from '../context'; 10 | import type { DocumentType } from "@typegoose/typegoose"; 11 | import { GraphQLError } from 'graphql'; 12 | 13 | const SECRET = "themfingsuperobvioussting"; 14 | const DEFAULT_EXPIRES_IN = "55d"; 15 | 16 | interface UserToken { 17 | id: string; 18 | } 19 | 20 | /** 21 | * takes a user token object and creates jwt out of it 22 | * using user.id and user.role 23 | * @param {Object} user the user to create a jwt for 24 | */ 25 | export function createToken(unsignedToken: UserToken): string { 26 | return sign({ id: unsignedToken.id }, SECRET, { expiresIn: DEFAULT_EXPIRES_IN }); 27 | } 28 | 29 | /** 30 | * will attempt to verify a jwt and find a user in the 31 | * db associated with it. Catches any error and returns 32 | * a null user 33 | * @param {String} token jwt from client 34 | * @throws {Error} if user cannot be found from specified token 35 | */ 36 | export async function getUserFromToken(encodedToken: string): Promise | null> { 37 | try { 38 | const jwtUserInfo = verify(encodedToken, SECRET) as UserToken; 39 | const user = await UserModel.findById(jwtUserInfo.id); 40 | 41 | if (user === null) { 42 | return null; 43 | } 44 | 45 | return user; 46 | } catch (e) { 47 | return null; 48 | } 49 | }; 50 | 51 | /** 52 | * TODO: 53 | * @param req 54 | */ 55 | export function getTokenFromReq(req: Request): string | null { 56 | const authReq = req?.headers.authorization; 57 | const regex = new RegExp("^Bearer .+"); 58 | if (!authReq || !regex.test(authReq)) { 59 | return null; 60 | } 61 | 62 | return authReq.replace("Bearer ", ""); 63 | }; 64 | 65 | export const customAuthChecker: AuthChecker = ( 66 | { context }: ResolverData, 67 | roles: ROLE[] 68 | ) => { 69 | if (!context.user) { 70 | throw new GraphQLError('Not authorized!', { 71 | extensions: { 72 | code: 'UNAUTHORIZED', 73 | }, 74 | }); 75 | } 76 | 77 | return roles.length == 0 || context.currMembership !== undefined && roles.includes(context.currMembership.role); 78 | }; 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-basics", 3 | "version": "1.0.0", 4 | "description": "Testing a GraphQL server on GCP", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node -r dotenv/config src/index", 8 | "test": "jest --runInBand" 9 | }, 10 | "jest": { 11 | "testEnvironment": "node", 12 | "transform": { 13 | "^.+\\.js?$": "babel-jest", 14 | "^.+\\.ts?$": "ts-jest" 15 | } 16 | }, 17 | "license": "MIT", 18 | "dependencies": { 19 | "@apollo/server": "^4.2.2", 20 | "@babel/helper-plugin-utils": "^7.19.0", 21 | "@babel/preset-env": "^7.19.1", 22 | "@babel/preset-typescript": "^7.18.6", 23 | "@babel/register": "^7.18.9", 24 | "@google-cloud/storage": "^6.5.0", 25 | "@sendgrid/mail": "^7.7.0", 26 | "@typegoose/typegoose": "^9.12.1", 27 | "@types/handlebars": "^4.1.0", 28 | "bcrypt": "^5.1.0", 29 | "bcryptjs": "^2.4.3", 30 | "body-parser": "^1.20.1", 31 | "class-validator": "^0.13.2", 32 | "cors": "^2.8.5", 33 | "dotenv": "^16.0.2", 34 | "email-validator": "^2.0.4", 35 | "expo-server-sdk": "^3.6.0", 36 | "express": "^4.18.1", 37 | "fs-capacitor": "^8.0.0", 38 | "global": "^4.4.0", 39 | "graphql": "^16.6.0", 40 | "graphql-tag": "^2.12.6", 41 | "graphql-type-json": "^0.3.2", 42 | "handlebars": "^4.7.7", 43 | "jest": "^29.0.3", 44 | "jsonwebtoken": "^8.5.1", 45 | "jwt-decode": "^3.1.2", 46 | "moment": "^2.29.4", 47 | "mongoose": "^6.6.1", 48 | "multer": "^1.4.5-lts.1", 49 | "node": "^17.9.0", 50 | "node-cron": "^3.0.2", 51 | "ts-node": "^10.9.1", 52 | "type-graphql": "^2.0.0-beta.1", 53 | "unfetch": "^4.2.0", 54 | "validator": "^13.7.0" 55 | }, 56 | "devDependencies": { 57 | "@babel/cli": "^7.18.10", 58 | "@babel/core": "^7.19.1", 59 | "@babel/node": "^7.19.1", 60 | "@types/bcrypt": "^5.0.0", 61 | "@types/bcryptjs": "^2.4.2", 62 | "@types/cors": "^2.8.13", 63 | "@types/express": "^4.17.14", 64 | "@types/jest": "^29.2.3", 65 | "@types/jsonwebtoken": "^8.5.9", 66 | "@types/mongoose": "^5.11.97", 67 | "@types/multer": "^1.4.7", 68 | "@types/node": "^18.11.2", 69 | "@types/node-cron": "^3.0.4", 70 | "@types/validator": "^13.7.10", 71 | "apollo": "^2.34.0", 72 | "babel-jest": "^29.3.1", 73 | "babel-plugin-graphql-tag": "^3.3.0", 74 | "eslint": "^8.25.0", 75 | "ts-jest": "^29.0.3", 76 | "typescript": "^4.9.3" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { ContextFunction } from '@apollo/server'; 2 | import { ExpressContextFunctionArgument } from '@apollo/server/dist/esm/express4'; 3 | import { DocumentType } from '@typegoose/typegoose'; 4 | import mongoose from 'mongoose'; 5 | 6 | import { Event } from '../models/Event'; 7 | import { EventModel, TokenModel, TroopModel, UserModel } from '../models/models'; 8 | import { Token } from '../models/Token'; 9 | import { Membership, Troop } from '../models/TroopAndPatrol'; 10 | import { User } from '../models/User'; 11 | import { getUserNotificationData, UserData } from './notifications'; 12 | import * as authFns from './utils/Auth'; 13 | 14 | import type { ReturnModelType } from '@typegoose/typegoose'; 15 | export interface ContextType { 16 | UserModel: ReturnModelType, 17 | EventModel: ReturnModelType, 18 | TroopModel: ReturnModelType, 19 | TokenModel: ReturnModelType, 20 | authFns: typeof authFns, 21 | tokens?: UserData[] | null, 22 | membershipIDString?: string, 23 | currMembership?: DocumentType, 24 | user?: DocumentType, 25 | } 26 | 27 | const contextFn: ContextFunction<[ExpressContextFunctionArgument]> = async ({ req }) => { 28 | let ret: ContextType = { 29 | UserModel, 30 | EventModel, 31 | TroopModel, 32 | TokenModel, 33 | authFns 34 | }; 35 | const token = authFns.getTokenFromReq(req); 36 | if (!token) { 37 | return ret; 38 | } 39 | const user = await authFns.getUserFromToken(token); 40 | if (!user) { 41 | return ret; 42 | } 43 | 44 | ret.user = user; 45 | 46 | // Update this for membership paradigm --(connie: not sure what this means but will leave the comment here ) 47 | const membership = Array.isArray(req.headers?.membership) ? req.headers?.membership[0] : req.headers?.membership; // this is really bad... 48 | 49 | const membershipIDString = membership === "undefined" ? undefined : new mongoose.Types.ObjectId(membership).toString(); 50 | 51 | if (membershipIDString && user.groups) { 52 | ret.membershipIDString = membershipIDString; 53 | const currMembership = user.groups.find((membership) => { 54 | return membership._id.equals(membershipIDString); 55 | }); 56 | if (currMembership) { 57 | ret.tokens = await getUserNotificationData(currMembership.troopID._id.toString()); 58 | ret.currMembership = currMembership; 59 | } 60 | } 61 | 62 | return ret; 63 | }; 64 | 65 | export default contextFn; -------------------------------------------------------------------------------- /src/resolvers/auth.ts: -------------------------------------------------------------------------------- 1 | import * as validator from 'email-validator'; 2 | import { Arg, Ctx, Field, ID, InputType, Mutation, ObjectType, Resolver } from 'type-graphql'; 3 | 4 | import { UserModel } from '../../models/models'; 5 | import { User } from '../../models/User'; 6 | import * as authFns from '../utils/Auth'; 7 | 8 | import type { ContextType } from '../context'; 9 | 10 | @InputType() 11 | export class LoginInput { 12 | @Field() 13 | email!: string; 14 | @Field() 15 | password!: string; 16 | @Field({ nullable: true }) 17 | expoNotificationToken?: string; 18 | } 19 | 20 | @InputType() 21 | export class SignupInput { 22 | @Field() 23 | name!: string; 24 | @Field() 25 | email!: string; 26 | @Field() 27 | password!: string; 28 | @Field() 29 | passwordConfirm!: string; 30 | @Field({ nullable: true }) 31 | expoNotificationToken?: string; 32 | @Field({ nullable: true }) 33 | phone?: string; 34 | @Field({ nullable: true }) 35 | birthday?: Date; 36 | } 37 | 38 | @ObjectType() 39 | export class SignupPayload { 40 | @Field() 41 | token!: string; 42 | @Field(type => User) 43 | user!: User; 44 | @Field() 45 | noGroups!: boolean; 46 | } 47 | 48 | @ObjectType() 49 | export class LoginPayload { 50 | @Field() 51 | token!: string; 52 | @Field(type => User) 53 | user!: User; 54 | @Field() 55 | noGroups!: boolean; 56 | @Field(type => ID, {nullable: true}) 57 | groupID?: string; 58 | } 59 | 60 | @Resolver() 61 | export class AuthResolver { 62 | @Mutation(returns => SignupPayload) 63 | async signup( 64 | @Arg("input") input: SignupInput, 65 | @Ctx() ctx: ContextType 66 | ): Promise { 67 | if (!validator.validate(input.email)) { 68 | throw new Error("Please enter a valid email."); 69 | } 70 | 71 | const user = await ctx.UserModel.create(input); 72 | 73 | const token = authFns.createToken({ 74 | id: user._id.toString() 75 | }); 76 | 77 | return { 78 | user, 79 | token, 80 | noGroups: true 81 | }; 82 | } 83 | 84 | @Mutation(returns => LoginPayload) 85 | async login( 86 | @Arg("input") input: LoginInput, 87 | @Ctx() ctx: ContextType 88 | ): Promise { 89 | const { email, password } = input; 90 | 91 | if (!email || !password) { 92 | throw new Error("Please provide an email and password."); 93 | } 94 | 95 | const user = await ctx.UserModel.findOne({ email }); 96 | 97 | if (!user || !(await user.isValidPassword(password))) { 98 | throw new Error("Invalid login"); 99 | } 100 | 101 | if (input.expoNotificationToken) { 102 | if (input.expoNotificationToken !== user.expoNotificationToken) { 103 | await UserModel.findByIdAndUpdate(user._id, { 104 | expoNotificationToken: input.expoNotificationToken, 105 | }); 106 | } 107 | } 108 | 109 | const token = authFns.createToken({ id: user._id.toString() }); 110 | return { 111 | token, 112 | user, 113 | noGroups: !user.groups.length, 114 | groupID: user.groups.length > 0 ? user.groups[0]!._id : undefined, 115 | }; 116 | } 117 | } -------------------------------------------------------------------------------- /src/notifications.ts: -------------------------------------------------------------------------------- 1 | import { isDocument } from '@typegoose/typegoose'; 2 | import { Expo, ExpoPushMessage } from 'expo-server-sdk'; 3 | import mongoose, { Error, Types } from 'mongoose'; 4 | 5 | import { TroopModel, UserModel } from '../models/models'; 6 | import { Notification } from '../models/Notification'; 7 | import { User } from '../models/User'; 8 | 9 | import type { DocumentType } from "@typegoose/typegoose"; 10 | 11 | let expo = new Expo(); 12 | 13 | export type UserData = { 14 | token?: string; 15 | userID: string; 16 | } 17 | 18 | // fill messages 19 | // TODO: is `troopID` a string that gets converted into a ObjectID automatically? 20 | export const getUserNotificationData = async (troopID: string): Promise> => { 21 | const userData: Array = []; 22 | 23 | const troop = await TroopModel.findById(troopID); 24 | 25 | if (!troop || !troop.patrols) { 26 | return []; 27 | } 28 | 29 | // patrols with not undefined members 30 | const validPatrols = troop.patrols.filter((patrol) => patrol.members.length); 31 | 32 | /** 33 | * TODO: 34 | * @param memberId 35 | */ 36 | const addUser = async (memberId: mongoose.Types.ObjectId): Promise => { 37 | const user = await UserModel.findById(memberId); 38 | if (!user) return Promise.resolve();; 39 | if (user.expoNotificationToken) { 40 | userData.push({ token: user.expoNotificationToken, userID: user.id }); 41 | } 42 | return Promise.resolve(); 43 | }; 44 | 45 | await Promise.all( 46 | validPatrols.map((patrol) => 47 | Promise.all(patrol.members.map((member) => addUser(member._id))) 48 | ) 49 | ); 50 | 51 | return userData; 52 | }; 53 | 54 | export const sendNotifications = async (userData: UserData[], body: string, data: {type: string, eventType: string, ID: string, notificationID?: Types.ObjectId}) => { 55 | let messages: ExpoPushMessage[] = []; 56 | for (let user of userData) { 57 | const { userID, token } = user; 58 | 59 | const notification: Notification = { 60 | _id: new mongoose.Types.ObjectId(), 61 | title: body, 62 | type: data.type, 63 | eventType: data.eventType, 64 | eventID: data.ID, 65 | }; 66 | 67 | let doc = await UserModel.findByIdAndUpdate(userID, {$push: {unreadNotifications: notification}}, {new: true}); 68 | 69 | if (!doc) { 70 | continue; 71 | } 72 | 73 | const notificationData = doc.unreadNotifications[doc.unreadNotifications.length - 1]; 74 | 75 | data = { ...data, notificationID: notificationData!._id }; 76 | 77 | if (!Expo.isExpoPushToken(token)) { 78 | console.error(`Push token ${token} is not a valid Expo push token`); 79 | return; 80 | } 81 | 82 | messages.push({ 83 | to: token, 84 | sound: "default", 85 | body, 86 | data, 87 | }); 88 | } 89 | 90 | let chunks = expo.chunkPushNotifications(messages); 91 | let tickets = []; 92 | (async () => { 93 | for (let chunk of chunks) { 94 | try { 95 | let ticketChunk = await expo.sendPushNotificationsAsync(chunk); 96 | tickets.push(...ticketChunk); 97 | } catch (error) { 98 | console.error(error); 99 | } 100 | } 101 | })(); 102 | }; 103 | -------------------------------------------------------------------------------- /models/TroopAndPatrol.ts: -------------------------------------------------------------------------------- 1 | import { modelOptions, prop as Property } from '@typegoose/typegoose'; 2 | import mongoose from 'mongoose'; 3 | import { Field, Float, ID, ObjectType, registerEnumType } from 'type-graphql'; 4 | 5 | import { Event, Point } from './Event'; 6 | import { User } from './User'; 7 | 8 | import type { Ref, ArraySubDocumentType } from '@typegoose/typegoose'; 9 | 10 | export enum ROLE { 11 | SCOUTMASTER = "SCOUTMASTER", 12 | ASST_SCOUTMASTER = "ASST_SCOUTMASTER", 13 | SENIOR_PATROL_LEADER = "SENIOR_PATROL_LEADER", 14 | ASST_PATROL_LEADER = "ASST_PATROL_LEADER", 15 | PATROL_LEADER = "PATROL_LEADER", 16 | SCOUT = "SCOUT", 17 | PARENT = "PARENT", 18 | ADULT_VOLUNTEER = "ADULT_VOLUNTEER", 19 | } 20 | 21 | registerEnumType(ROLE, { 22 | name: "Role", 23 | description: "A user's role within a patrol" 24 | }); 25 | 26 | @ObjectType() 27 | export class Location { 28 | @Field(type => Float) 29 | lat!: number; 30 | @Field(type => Float) 31 | lng!: number; 32 | @Field({nullable: true}) 33 | address?: string; 34 | } 35 | 36 | @ObjectType() 37 | export class Membership { 38 | @Field(type => ID, {name: "id"}) 39 | readonly _id: mongoose.Types.ObjectId; 40 | 41 | @Field(type => ID) 42 | @Property({ required: true, ref: () => Troop }) 43 | public troopID!: Ref; 44 | 45 | @Field(type => ID) 46 | @Property({ required: true }) 47 | public troopNumber!: string; 48 | 49 | @Field(type => ID) 50 | @Property({ required: true, ref: () => Patrol }) 51 | public patrolID!: Ref; 52 | 53 | @Field(type => ROLE) 54 | @Property({ required: true, enum: ROLE }) 55 | public role!: ROLE; 56 | } 57 | 58 | @modelOptions({ 59 | schemaOptions: { 60 | toJSON: { virtuals: true }, 61 | toObject: { virtuals: true }, 62 | timestamps: true 63 | } 64 | }) 65 | @ObjectType() 66 | export class Patrol { 67 | @Field(type => ID, {name: "id"}) 68 | readonly _id: mongoose.Types.ObjectId; 69 | 70 | @Field() 71 | @Property({ required: true }) 72 | public name!: string; 73 | 74 | @Property({ required: true, ref: () => User, default: [] }) 75 | public members!: Ref[]; 76 | 77 | @Property({ required: true, ref: () => Event, default: [] }) 78 | public events!: Ref[]; 79 | } 80 | 81 | @modelOptions({ 82 | schemaOptions: { 83 | toJSON: { virtuals: true }, 84 | toObject: { virtuals: true }, 85 | timestamps: true 86 | } 87 | }) 88 | @ObjectType() 89 | export class Troop { 90 | @Field(type => ID, {name: "id"}) 91 | readonly _id: mongoose.Types.ObjectId; 92 | 93 | @Field() 94 | @Property({ required: true }) 95 | public council!: string; 96 | 97 | @Field() 98 | @Property({ required: true }) 99 | public state!: string; 100 | 101 | @Field() 102 | @Property({ required: true }) 103 | public unitNumber!: number; 104 | 105 | @Field() 106 | @Property({ required: true }) 107 | public city: string; 108 | 109 | @Field(type => User, {nullable: true}) 110 | @Property({ref: () => User}) 111 | public scoutMaster?: Ref; 112 | 113 | @Field(type => Location, {nullable: true}) 114 | @Property() 115 | public meetLocation?: Point; 116 | 117 | @Field(type => [Patrol]) 118 | @Property({ required: true, type: () => [Patrol], default: [] }) 119 | public patrols!: mongoose.Types.DocumentArray>; 120 | 121 | // @Field(type => [Event]) 122 | // @Property({ ref: () => Event }) 123 | // public events?: Ref[]; 124 | } 125 | -------------------------------------------------------------------------------- /models/User.ts: -------------------------------------------------------------------------------- 1 | import { modelOptions, pre, prop as Property } from '@typegoose/typegoose'; 2 | import bcrypt from 'bcrypt'; 3 | import mongoose from 'mongoose'; 4 | import { Field, ID, Int, ObjectType } from 'type-graphql'; 5 | import validator from 'validator'; 6 | 7 | import { Notification } from './Notification'; 8 | import { Membership } from './TroopAndPatrol'; 9 | 10 | import type { ArraySubDocumentType, Ref } from "@typegoose/typegoose"; 11 | import { UserModel } from './models'; 12 | 13 | const DEFAULT_USER_PHOTO_URL = "https://res.cloudinary.com/wow-your-client/image/upload/c_scale,w_250/v1645286759/ScoutTrek/DefaultProfile.png"; 14 | 15 | @modelOptions({ 16 | schemaOptions: { 17 | timestamps: true, 18 | } 19 | }) 20 | @pre("save", async function (next) { 21 | if (!this.isModified("password")) return next(); 22 | 23 | this.password = await bcrypt.hash(this.password, 12); 24 | this.passwordConfirm = undefined; 25 | next(); 26 | }) 27 | @ObjectType() 28 | export class User { 29 | @Field(type => ID, {name: "id"}) 30 | readonly _id: mongoose.Types.ObjectId; 31 | 32 | @Field() 33 | @Property({ 34 | required: [true, "You must insert your name to create a valid user."], 35 | trim: true 36 | }) 37 | public name!: string; 38 | 39 | @Field() 40 | @Property({ 41 | required: [true, "To create a valid user you must enter an email address."], 42 | unique: false, 43 | lowercase: true, 44 | validate: [validator.isEmail, "Please provide a valid email."] 45 | }) 46 | public email!: string; 47 | 48 | @Field() 49 | @Property({ required: true, default: DEFAULT_USER_PHOTO_URL }) 50 | public userPhoto!: string; 51 | 52 | @Property({ 53 | required: true, 54 | minlength: 8, 55 | select: false 56 | }) 57 | public password!: string; 58 | 59 | @Property({ 60 | validate: { 61 | validator: function (el: string) { 62 | return el === this.password; 63 | }, 64 | message: "Passwords do not match" 65 | } 66 | }) 67 | public passwordConfirm?: string; 68 | 69 | @Field({nullable: true}) 70 | @Property() 71 | public expoNotificationToken?: string; 72 | 73 | @Field({nullable: true}) 74 | @Property({ 75 | validate: [validator.isMobilePhone, "Please provide a valid phone number"], 76 | minlength: 10, 77 | maxlength: 11 78 | }) 79 | public phone?: string; 80 | 81 | @Field({ nullable: true }) 82 | @Property() 83 | public birthday?: Date; 84 | 85 | @Field(type => [Membership]) 86 | @Property({ required: true, type: () => Membership, default: [] }) 87 | public groups!: mongoose.Types.Array>; 88 | 89 | @Field(type => [String]) 90 | @Property({ required: true, type: () => [String], default: [] }) 91 | public children!: string[]; 92 | 93 | @Field(type => [Notification]) 94 | @Property({ required: true, type: () => Notification, default: [] }) 95 | public unreadNotifications!: mongoose.Types.DocumentArray>; 96 | 97 | // @Field(type => [Event]) 98 | // @Property({ required: true, ref: () => Event, default: [] }) 99 | // public events!: Ref[]; 100 | 101 | @Field({nullable: true}) 102 | createdAt?: Date; 103 | 104 | @Field({nullable: true}) 105 | updatedAt?: Date; 106 | 107 | @Field(type => Int, {nullable: true}) 108 | age(): number | null { 109 | return this.birthday ? Math.floor((Date.now() - this.birthday.getTime()) / 1000 / 60 / 60 / 24 / 365) : null; 110 | } 111 | 112 | @Field() 113 | noGroups(): boolean { 114 | return this.groups.length === 0; 115 | } 116 | 117 | /** 118 | * TODO: 119 | */ 120 | public async isValidPassword( 121 | submittedPass: string 122 | ): Promise { 123 | const currPass = (await UserModel.findOne({ _id: this._id }).select("password"))?.password; 124 | if (currPass === undefined) { 125 | return false; 126 | } 127 | return await bcrypt.compare(submittedPass, currPass); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /tests/resolvers/auth.test.ts: -------------------------------------------------------------------------------- 1 | // Models 2 | import { ApolloServer, GraphQLResponse } from '@apollo/server'; 3 | import assert from 'assert'; 4 | import { gql } from 'graphql-tag'; 5 | import { ContextType } from 'src/context'; 6 | import { buildSchemaSync } from 'type-graphql'; 7 | 8 | import { UserModel } from '../../models/models'; 9 | import { TypegooseMiddleware } from '../../src/middleware/typegoose_middlware'; 10 | import { AuthResolver, SignupPayload } from '../../src/resolvers/auth'; 11 | import { PatrolResolver } from '../../src/resolvers/patrol'; 12 | import { TroopResolver } from '../../src/resolvers/troop'; 13 | import { UserResolver } from '../../src/resolvers/user'; 14 | import * as authFns from '../../src/utils/Auth'; 15 | import createTestContext from '../utils/test_context'; 16 | import { setupDB } from '../utils/test_setup'; 17 | 18 | setupDB('scouttrek-test'); 19 | 20 | const schema = buildSchemaSync({ 21 | resolvers: [AuthResolver, UserResolver, TroopResolver, PatrolResolver], 22 | globalMiddlewares: [TypegooseMiddleware], 23 | authChecker: authFns.customAuthChecker, 24 | }); 25 | 26 | // Create GraphQL server 27 | const server = new ApolloServer({ 28 | schema, 29 | }); 30 | 31 | describe("User signup", () => { 32 | describe("Create a new user", () => { 33 | let response: GraphQLResponse; 34 | 35 | beforeEach(async () => { 36 | const createUser = gql` 37 | mutation { 38 | signup( 39 | input: { 40 | name: "Test User" 41 | email: "test@example.com" 42 | password: "password" 43 | passwordConfirm: "password" 44 | phone: "1234567890" 45 | birthday: "2000-12-12" 46 | } 47 | ) { 48 | user { 49 | id 50 | name 51 | email 52 | phone 53 | birthday 54 | } 55 | token 56 | noGroups 57 | } 58 | } 59 | `; 60 | response = await server.executeOperation({ 61 | query: createUser, 62 | }, { 63 | contextValue: await createTestContext(), 64 | }); 65 | }); 66 | 67 | test('correct fields should be returned', async () => { 68 | assert(response.body.kind === 'single'); 69 | const signupResponse = response.body.singleResult.data?.signup as SignupPayload; 70 | const createdUser = signupResponse.user as any; // Necessary because of renaming _id to id 71 | expect(createdUser.name).toBe("Test User"); 72 | expect(createdUser.email).toBe("test@example.com"); 73 | expect(createdUser.phone).toBe("1234567890"); 74 | expect(new Date(createdUser.birthday!).getTime()).toBe(new Date("2000-12-12").getTime()); 75 | expect(signupResponse.token).toBe(authFns.createToken({id: createdUser.id})); 76 | expect(signupResponse.noGroups).toBe(true); 77 | }); 78 | 79 | test('user should be created in the db', async () => { 80 | assert(response.body.kind === 'single'); 81 | const signupResponse = response.body.singleResult.data?.signup as any; // Necessary because of renaming _id to id 82 | const count = await UserModel.count({ _id: signupResponse.user.id }); 83 | expect(count).toBe(1); 84 | }); 85 | }); 86 | 87 | it('should fail if passwords do not match', async () => { 88 | const createUser = gql` 89 | mutation { 90 | signup( 91 | input: { 92 | name: "Test User" 93 | email: "test@example.com" 94 | password: "password1" 95 | passwordConfirm: "password2" 96 | phone: "1234567890" 97 | birthday: "2000-12-12" 98 | } 99 | ) { 100 | token 101 | } 102 | } 103 | `; 104 | const result = await server.executeOperation({ 105 | query: createUser, 106 | }, { 107 | contextValue: await createTestContext() 108 | }); 109 | assert(result.body.kind === "single"); 110 | expect(result.body.singleResult.errors).toHaveLength(1); 111 | expect(result.body.singleResult.errors![0]?.message).toBe("User validation failed: passwordConfirm: Passwords do not match") 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/resolvers/troop.ts: -------------------------------------------------------------------------------- 1 | import { User } from 'models/User'; 2 | import mongoose from 'mongoose'; 3 | import { 4 | Arg, 5 | Authorized, 6 | Ctx, 7 | Field, 8 | FieldResolver, 9 | ID, 10 | InputType, 11 | Int, 12 | Mutation, 13 | Query, 14 | Resolver, 15 | Root, 16 | } from 'type-graphql'; 17 | import { Location, Troop } from '../../models/TroopAndPatrol'; 18 | import type { ContextType } from '../context'; 19 | 20 | // note: some resolvers in the old backend (such as updating troops) weren't implemented, hence why 21 | // we have a lot of code commented out in this file. these may be worth looking into/implementing later. 22 | 23 | @InputType() 24 | class AddTroopInput implements Partial { 25 | @Field() 26 | council!: string; 27 | @Field() 28 | state!: string; 29 | @Field(type => Int, {nullable: true}) 30 | unitNumber!: number; 31 | @Field({nullable: true}) 32 | city?: string; 33 | @Field(type => ID, {nullable: true}) 34 | scoutMaster?: mongoose.Types.ObjectId; 35 | // @Field() 36 | // meetLocation?: AddLocationInput 37 | // @Field(type => [ID], {nullable: true, name: "patrols"}) 38 | // patrolIds?: mongoose.Types.ObjectId[]; 39 | // @Field(type => [ID], {nullable: true, name: "events"}) 40 | // eventIds?: mongoose.Types.ObjectId[]; 41 | } 42 | 43 | // @InputType() 44 | // class UpdateTroopInput implements Partial { 45 | // @Field({nullable: true}) 46 | // council?: string; 47 | // @Field({nullable: true}) 48 | // state?: string; 49 | // @Field(type => Int, {nullable: true}) 50 | // unitNumber?: number; 51 | // @Field({nullable: true}) 52 | // city?: string; 53 | // @Field(type => ID, {nullable: true}) 54 | // scoutMaster?: mongoose.Types.ObjectId; 55 | // // @Field() 56 | // // meetLocation?: AddLocationInput 57 | // @Field(type => [ID], {nullable: true, name: "patrols"}) 58 | // patrolIds?: mongoose.Types.ObjectId[]; 59 | // @Field(type => [ID], {nullable: true}) 60 | // eventIds?: mongoose.Types.ObjectId[]; 61 | // } 62 | 63 | @Resolver(of => Troop) 64 | export class TroopResolver { 65 | @Query(returns => [Troop]) 66 | async troops( 67 | @Arg("limit", type => Int, {nullable: true}) limit: number, 68 | @Arg("skip", type => Int, {nullable: true}) skip: number, 69 | @Ctx() ctx: ContextType 70 | ): Promise { 71 | return await ctx.TroopModel.find({}, null, { limit, skip }) 72 | } 73 | 74 | @Query(returns => Troop) 75 | async troop( 76 | @Arg("id", type => ID) id: string, 77 | @Ctx() ctx: ContextType 78 | ): Promise { 79 | return await ctx.TroopModel.findById(id); 80 | } 81 | 82 | @Authorized() 83 | @Query(returns => Troop) 84 | async currTroop( 85 | @Ctx() ctx: ContextType 86 | ): Promise { 87 | if (ctx.currMembership === undefined) { 88 | throw new Error("No membership selected!"); 89 | } 90 | return await ctx.TroopModel.findById(ctx.currMembership.troopID._id); 91 | } 92 | 93 | @Authorized() 94 | @Mutation(returns => Troop) 95 | async addTroop( 96 | @Arg("input") input: AddTroopInput, 97 | @Ctx() ctx: ContextType 98 | ): Promise { 99 | return await ctx.TroopModel.create(input); 100 | } 101 | 102 | @FieldResolver(returns => Location) 103 | meetLocation(@Root() troop: Troop, @Ctx() ctx: ContextType): Location | null { 104 | if (troop.meetLocation && troop.meetLocation.coordinates.length == 2) { 105 | return { 106 | lng: troop.meetLocation.coordinates[0]!, 107 | lat: troop.meetLocation.coordinates[1]!, 108 | address: troop.meetLocation.address, 109 | }; 110 | } 111 | return null; 112 | } 113 | 114 | // TODO: The following were missing from the existing resolvers? 115 | // @Mutation() 116 | // async updateTroop( 117 | // @Arg("id", type => ID) id: mongoose.Types.ObjectId, 118 | // @Arg("input") input: UpdateTroopInput, 119 | // @Ctx() ctx: ContextType 120 | // ): Promise { 121 | 122 | // } 123 | 124 | // @Mutation() 125 | // updateCurrTroop( 126 | // @Arg("input") input: UpdateTroopInput, 127 | // @Ctx() ctx: ContextType 128 | // ): Troop { 129 | 130 | // } 131 | 132 | // @Mutation() 133 | // deleteTroop( 134 | // @Arg("id", type => ID) id: mongoose.Types.ObjectId, 135 | // @Ctx() ctx: ContextType 136 | // ): Troop { 137 | 138 | // } 139 | 140 | // @Mutation() 141 | // deleteCurrTroop(@Ctx() ctx: ContextType): Troop { 142 | 143 | // } 144 | 145 | @FieldResolver() 146 | async scoutMaster(@Root() troop: Troop, @Ctx() ctx: ContextType): Promise { 147 | return await ctx.UserModel.findById(troop.scoutMaster?._id); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /models/Event.ts: -------------------------------------------------------------------------------- 1 | import { prop as Property } from '@typegoose/typegoose'; 2 | import mongoose from 'mongoose'; 3 | import { Field, ID, ObjectType, registerEnumType } from 'type-graphql'; 4 | 5 | import { Roster } from './Roster'; 6 | import { Patrol, Troop } from './TroopAndPatrol'; 7 | import { User } from './User'; 8 | 9 | import type { Ref } from "@typegoose/typegoose"; 10 | export const DAYS_OF_WEEK = [ 11 | "Monday", 12 | "Tuesday", 13 | "Wednesday", 14 | "Thursday", 15 | "Friday", 16 | "Saturday", 17 | "Sunday", 18 | ] as const; 19 | 20 | export enum EVENT_TYPE { 21 | AQUATIC_EVENT = "AQUATIC_EVENT", 22 | BACKPACK_TRIP = "BACKPACK_TRIP", 23 | BIKE_RIDE = "BIKE_RIDE", 24 | BOARD_OF_REVIEW = "BOARD_OF_REVIEW", 25 | CAMPOUT = "CAMPOUT", 26 | CANOE_TRIP = "CANOE_TRIP", 27 | COMMITTEE_MEETING = "COMMITTEE_MEETING", 28 | CUSTOM_EVENT = "CUSTOM_EVENT", 29 | EAGLE_PROJECT = "EAGLE_PROJECT", 30 | FISHING_TRIP = "FISHING_TRIP", 31 | FLAG_RETIREMENT = "FLAG_RETIREMENT", 32 | FUNDRAISER = "FUNDRAISER", 33 | TROOP_MEETING = "TROOP_MEETING", 34 | HIKE = "HIKE", 35 | KAYAK_TRIP = "KAYAK_TRIP", 36 | MERIT_BADGE_CLASS = "MERIT_BADGE_CLASS", 37 | PARENT_MEETING = "PARENT_MEETING", 38 | SCOUTMASTER_CONFERENCE = "SCOUTMASTER_CONFERENCE", 39 | SERVICE_PROJECT = "SERVICE_PROJECT", 40 | SPECIAL_EVENT = "SPECIAL_EVENT", 41 | SUMMER_CAMP = "SUMMER_CAMP", 42 | SWIM_TEST = "SWIM_TEST", 43 | } 44 | 45 | registerEnumType(EVENT_TYPE, { 46 | name: "EventType", 47 | }); 48 | 49 | export class Point { 50 | @Property({ required: true, enum: ["Point"] as const }) 51 | public type!: string; 52 | 53 | @Property({ required: true, type: () => [Number] }) 54 | public coordinates!: number[]; 55 | 56 | @Property() 57 | public address?: string; 58 | } 59 | 60 | export class MessageUser { 61 | @Property({ required: true }) 62 | public name!: string; 63 | } 64 | 65 | export class Message { 66 | @Property() 67 | public text?: string; 68 | 69 | @Property() 70 | public image?: string; 71 | 72 | @Property({ default: Date.now }) 73 | public createdAt?: Date; 74 | 75 | @Property() 76 | public user?: MessageUser; 77 | } 78 | 79 | @ObjectType() 80 | export class Event { 81 | @Field(type => ID, {name: "id"}) 82 | readonly _id: mongoose.Types.ObjectId; 83 | 84 | @Field(type => EVENT_TYPE) 85 | @Property({ required: true, enum: EVENT_TYPE }) 86 | public type!: EVENT_TYPE; 87 | 88 | @Field(type => Troop) 89 | @Property({ required: true, ref: () => Troop }) 90 | public troop!: Ref; 91 | 92 | @Field(type => Patrol) 93 | @Property({ required: true, ref: () => Patrol }) 94 | public patrol!: Ref; 95 | 96 | @Field() 97 | @Property({ required: [true, "An event cannot have a blank title."] }) 98 | public title!: string; 99 | 100 | @Field({ nullable: true }) 101 | @Property() 102 | public description?: string; 103 | 104 | @Field({ nullable: true }) 105 | @Property() 106 | public date?: Date; 107 | 108 | @Field({ nullable: true }) 109 | @Property() 110 | public endDate?: Date; 111 | 112 | @Field({ nullable: true }) 113 | @Property() 114 | public startTime?: string; 115 | 116 | @Field({ nullable: true }) 117 | @Property() 118 | public uniqueMeetLocation?: string; 119 | 120 | @Field({ nullable: true }) 121 | @Property() 122 | public meetTime?: Date; 123 | 124 | @Field({ nullable: true }) 125 | @Property() 126 | public leaveTime?: Date; 127 | 128 | @Field({ nullable: true }) 129 | @Property() 130 | public pickupTime?: Date; 131 | 132 | @Field({ nullable: true }) 133 | @Property() 134 | public endTime?: string; 135 | 136 | @Property() 137 | public locationPoint?: Point; 138 | 139 | @Property() 140 | public meetLocationPoint?: Point; 141 | 142 | @Property({ required: true, type: () => [Message], default: [] }) 143 | public messages!: Message[]; 144 | 145 | @Field(type => Roster) 146 | @Property({ required: true, default(this: Event) { 147 | return {eventId: this._id, yes: [], no: [], maybe: []} 148 | }}) 149 | public roster!: Roster; 150 | 151 | @Property({ enum: DAYS_OF_WEEK }) 152 | public day?: string; 153 | 154 | @Field({ nullable: true }) 155 | @Property() 156 | public distance?: number; 157 | 158 | @Property() 159 | public shakedown?: boolean; 160 | 161 | @Property() 162 | public published?: boolean; 163 | 164 | @Field(type => User, { nullable: true }) 165 | @Property({ ref: () => User }) 166 | public creator?: Ref; 167 | 168 | @Property() 169 | public notification?: Date; 170 | 171 | @Field({nullable: true}) 172 | createdAt?: string; 173 | 174 | @Field({nullable: true}) 175 | updatedAt?: string; 176 | } 177 | 178 | // type Location { 179 | // lat: Float! 180 | // lng: Float! 181 | // address: String 182 | // } -------------------------------------------------------------------------------- /src/resolvers/patrol.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { Arg, Authorized, Ctx, Field, FieldResolver, ID, InputType, Mutation, Query, Resolver, Root } from 'type-graphql'; 3 | 4 | import { Patrol, ROLE } from '../../models/TroopAndPatrol'; 5 | import { User } from '../../models/User'; 6 | 7 | import type { ContextType } from '../context'; 8 | 9 | @InputType() 10 | class AddPatrolInput implements Partial { 11 | @Field() 12 | name: string; 13 | @Field(type => [ID], {nullable: true}) 14 | members?: mongoose.Types.ObjectId[]; 15 | @Field(type => [ID], {nullable: true}) 16 | events?: mongoose.Types.ObjectId[]; 17 | } 18 | 19 | @InputType() 20 | class UpdatePatrolInput implements Partial { 21 | @Field({nullable: true}) 22 | name?: string; 23 | @Field(type => [ID], {nullable: true}) 24 | members?: mongoose.Types.ObjectId[]; 25 | @Field(type => [ID], {nullable: true}) 26 | events?: mongoose.Types.ObjectId[]; 27 | } 28 | 29 | @Resolver(of => Patrol) 30 | export class PatrolResolver { 31 | @Authorized() 32 | @Query(returns => [Patrol]) 33 | async patrols( 34 | @Ctx() ctx: ContextType 35 | ): Promise { 36 | if (ctx.currMembership === undefined) { 37 | throw new Error("No membership selected!"); 38 | } 39 | const myTroop = await ctx.TroopModel.findById(ctx.currMembership.troopID._id); 40 | if (myTroop === null) { 41 | throw new Error("Selected troop does not exist"); 42 | } 43 | return myTroop.patrols; 44 | } 45 | 46 | @Query(returns => [Patrol]) 47 | async patrolsOfTroop( 48 | @Arg("id", type => ID) id: string, 49 | @Ctx() ctx: ContextType 50 | ): Promise { 51 | const myTroop = await ctx.TroopModel.findById(id); 52 | if (myTroop === null) { 53 | throw new Error("Selected troop does not exist"); 54 | } 55 | return myTroop.patrols; 56 | } 57 | 58 | @Authorized() 59 | @Query(returns => Patrol) 60 | async patrol( 61 | @Arg("id", type => ID) id: string, 62 | @Ctx() ctx: ContextType 63 | ): Promise { 64 | if (ctx.currMembership === undefined) { 65 | throw new Error("No membership selected!"); 66 | } 67 | const myTroop = await ctx.TroopModel.findById(ctx.currMembership.troopID._id); 68 | if (myTroop === null) { 69 | throw new Error("Selected troop does not exist"); 70 | } 71 | return myTroop.patrols.id(id); 72 | } 73 | 74 | @Authorized() 75 | @Query(returns => Patrol) 76 | async currPatrol( 77 | @Ctx() {currMembership, TroopModel}: ContextType 78 | ): Promise { 79 | if (currMembership === undefined) { 80 | throw new Error("No membership selected!"); 81 | } 82 | const myTroop = await TroopModel.findById(currMembership.troopID._id); 83 | if (myTroop === null) { 84 | throw new Error("Selected troop does not exist"); 85 | } 86 | return myTroop.patrols.id(currMembership.patrolID._id); 87 | } 88 | 89 | // Will need to figure out how to create a Troop and a Patrol at the same time. 90 | @Mutation(returns => Patrol) 91 | async addPatrol( 92 | @Arg("troopId", type => ID) troopId: mongoose.Types.ObjectId, 93 | @Arg("input") input: AddPatrolInput, 94 | @Ctx() ctx: ContextType 95 | ): Promise { 96 | const troop = await ctx.TroopModel.findById(troopId); 97 | if (troop == null) { 98 | throw new Error("Troop does not exist"); 99 | } 100 | troop.patrols.push(input); 101 | 102 | troop.save(function (err) { 103 | if (err) return new Error(err.message); 104 | }); 105 | 106 | return troop.patrols[troop.patrols.length - 1]!; 107 | } 108 | 109 | @Authorized([ROLE.PATROL_LEADER]) 110 | @Mutation(returns => Patrol) 111 | async updatePatrol( 112 | @Arg("id", type => ID) id: mongoose.Types.ObjectId, 113 | @Arg("input") input: UpdatePatrolInput, 114 | @Ctx() ctx: ContextType 115 | ): Promise { 116 | if (ctx.currMembership === undefined) { 117 | throw new Error("No membership selected!"); 118 | } 119 | const troop = await ctx.TroopModel.findById(ctx.currMembership.troopID._id); 120 | if (troop === null) { 121 | throw new Error("Selected troop does not exist"); 122 | } 123 | const patrol = troop.patrols.id(id); 124 | if (patrol === null) { 125 | throw new Error("Patrol does not exist"); 126 | } 127 | const updatedPatrol = { ...patrol, ...input }; 128 | patrol.set(updatedPatrol); 129 | await troop.save(); 130 | return patrol; 131 | } 132 | 133 | @Authorized() 134 | @Mutation(returns => Patrol) 135 | async updateCurrPatrol( 136 | @Arg("input") input: UpdatePatrolInput, 137 | @Ctx() ctx: ContextType 138 | ): Promise { 139 | if (ctx.currMembership === undefined) { 140 | throw new Error("No membership selected!"); 141 | } 142 | const troop = await ctx.TroopModel.findById(ctx.currMembership.troopID._id); 143 | if (troop === null) { 144 | throw new Error("Selected troop does not exist"); 145 | } 146 | const patrol = troop.patrols.id(ctx.currMembership.patrolID._id); 147 | if (patrol === null) { 148 | throw new Error("Patrol does not exist"); 149 | } 150 | const updatedPatrol = { ...patrol, ...input }; 151 | patrol.set(updatedPatrol); 152 | await troop.save(); 153 | return patrol; 154 | } 155 | 156 | @Authorized([ROLE.PATROL_LEADER]) 157 | @Mutation(returns => Patrol) 158 | async deletePatrol( 159 | @Arg("id", type => ID) id: mongoose.Types.ObjectId, 160 | @Ctx() ctx: ContextType 161 | ): Promise { 162 | // Not tested because I don't have very much data in the DB yet. 163 | if (ctx.currMembership === undefined) { 164 | throw new Error("No membership selected!"); 165 | } 166 | const troop = await ctx.TroopModel.findById(ctx.currMembership.troopID._id); 167 | if (troop === null) { 168 | throw new Error("Selected troop does not exist"); 169 | } 170 | const patrol = troop.patrols.id(id); 171 | if (patrol === null) { 172 | throw new Error("Patrol does not exist"); 173 | } 174 | return await patrol.remove(); 175 | } 176 | 177 | @Authorized([ROLE.PATROL_LEADER]) 178 | @Mutation(returns => Patrol) 179 | async deleteCurrPatrol(@Ctx() ctx: ContextType): Promise { 180 | // Not tested because I don't have very much data in the DB yet. 181 | if (ctx.currMembership === undefined) { 182 | throw new Error("No membership selected!"); 183 | } 184 | const troop = await ctx.TroopModel.findById(ctx.currMembership.troopID._id); 185 | if (troop === null) { 186 | throw new Error("Selected troop does not exist"); 187 | } 188 | const patrol = troop.patrols.id(ctx.currMembership.patrolID); 189 | if (patrol === null) { 190 | throw new Error("Patrol does not exist"); 191 | } 192 | return await patrol.remove(); 193 | } 194 | 195 | // TODO: this doesn't actually get the patrol's troop 196 | // @Authorized() 197 | // @FieldResolver(returns => Troop) 198 | // async troop(@Ctx() ctx: ContextType): Promise { 199 | // if (ctx.currMembership) { 200 | // return await ctx.TroopModel.findById(ctx.currMembership.troop._id) ?? undefined; 201 | // } 202 | // } 203 | 204 | @FieldResolver(returns => [User]) 205 | async members(@Root() patrol: Patrol, @Ctx() ctx: ContextType): Promise { 206 | return await ctx.UserModel.find().where("_id").in(patrol.members.map((m: any) => m._id)); 207 | } 208 | 209 | // @FieldResolver(returns => [Event]) 210 | // async events(@Root() patrol: Patrol, @Ctx() ctx: ContextType): Promise { 211 | // const events = await ctx.EventModel.find().where("_id").in(patrol.events.map(e => e._id)); 212 | // return events; 213 | // } 214 | } -------------------------------------------------------------------------------- /src/resolvers/user.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import crypto from 'crypto'; 3 | import { GraphQLError } from 'graphql'; 4 | import mongoose, { Error } from 'mongoose'; 5 | import * as EmailService from '../services/email'; 6 | import { 7 | Arg, 8 | Authorized, 9 | Ctx, 10 | Field, 11 | FieldResolver, 12 | ID, 13 | InputType, 14 | Int, 15 | Mutation, 16 | ObjectType, 17 | Query, 18 | Resolver, 19 | Root, 20 | } from 'type-graphql'; 21 | 22 | import { TOKEN_TYPE } from '../../models/Token'; 23 | import { Membership, Patrol, ROLE, Troop } from '../../models/TroopAndPatrol'; 24 | import { User } from '../../models/User'; 25 | 26 | import type { ContextType } from '../context'; 27 | 28 | @InputType() 29 | class AddMembershipInput implements Partial { 30 | @Field(type => ID) 31 | troopID!: mongoose.Types.ObjectId; 32 | 33 | @Field(type => ID) 34 | troopNumber!: string; 35 | 36 | @Field(type => ID) 37 | patrolID!: mongoose.Types.ObjectId; 38 | 39 | @Field(type => ROLE) 40 | role!: ROLE; 41 | 42 | @Field(type => [String], {nullable: true}) 43 | children?: string[]; 44 | } 45 | 46 | @ObjectType() 47 | class MembershipPayload implements Partial { 48 | @Field(type => ID) 49 | groupID: mongoose.Types.ObjectId; 50 | @Field() 51 | troopNumber: string; 52 | } 53 | 54 | @InputType() 55 | class UpdateUserInput { // implements Partial 56 | @Field({nullable: true}) 57 | name?: string; 58 | @Field({nullable: true}) 59 | email?: string; 60 | @Field({nullable: true}) 61 | expoNotificationToken?: string; 62 | @Field({nullable: true}) 63 | password?: string; 64 | @Field({nullable: true}) 65 | phone?: string; 66 | @Field({nullable: true}) 67 | birthday?: Date; 68 | @Field(type => [AddMembershipInput], {nullable: true}) 69 | groups?: AddMembershipInput[]; 70 | @Field(type => [String], {nullable: true}) 71 | children?: string[]; 72 | } 73 | 74 | @InputType() 75 | class ResetPasswordInput { 76 | @Field() 77 | email!: string; 78 | @Field() 79 | token!: string; 80 | @Field() 81 | password!: string; 82 | } 83 | 84 | @Resolver(of => User) 85 | export class UserResolver { 86 | @Authorized() 87 | @Query(returns => User) 88 | async user( 89 | @Arg("id", type => ID) id: string, 90 | @Ctx() ctx: ContextType 91 | ): Promise { 92 | const user = await ctx.UserModel.findById(id); 93 | if (!user) { 94 | throw new Error("User could not be found") 95 | } 96 | return user; 97 | } 98 | 99 | @Authorized() 100 | @Query(returns => [User]) 101 | async users( 102 | @Arg("limit", type => Int, {nullable: true}) limit: number, 103 | @Arg("skip", type => Int, {nullable: true}) skip: number, 104 | @Ctx() ctx: ContextType 105 | ): Promise { 106 | return await ctx.UserModel.find({}, null, { limit, skip }) 107 | } 108 | 109 | @Authorized() 110 | @Query(returns => User, {nullable: true}) 111 | async currUser(@Ctx() ctx: ContextType): Promise { 112 | return ctx.user; 113 | } 114 | 115 | @Authorized() 116 | @Mutation(returns => MembershipPayload) 117 | async addGroup( 118 | @Arg("input") input: AddMembershipInput, 119 | @Ctx() ctx: ContextType 120 | ): Promise { 121 | const newGroupID = new mongoose.Types.ObjectId(); 122 | 123 | const troopDoc = await ctx.TroopModel.findById(input.troopID); 124 | if (!troopDoc) { 125 | throw new Error("No such troop"); 126 | } 127 | if (input.role === ROLE.SCOUTMASTER) { 128 | troopDoc.scoutMaster = ctx.user!._id; 129 | } 130 | if (input.patrolID) { 131 | const patrol = troopDoc.patrols.id(input.patrolID); 132 | if (!patrol) { 133 | throw new Error("No such patrol"); 134 | } 135 | patrol.members.push(ctx.user!._id); 136 | } 137 | troopDoc.save(); 138 | 139 | const { children, ...membershipDetails } = input; 140 | if (children) { 141 | ctx.user!.children?.push(...children); 142 | } 143 | ctx.user!.groups.push({ _id: newGroupID, ...membershipDetails }); 144 | ctx.user!.save(); 145 | 146 | return { 147 | groupID: newGroupID, 148 | troopNumber: input.troopNumber 149 | }; 150 | } 151 | 152 | @Authorized() 153 | @Mutation(returns => User) 154 | async updateUser( 155 | @Arg("input") input: UpdateUserInput, 156 | @Arg("id", type => ID) id: string, 157 | @Ctx() ctx: ContextType 158 | ): Promise { 159 | if (id !== ctx.user!._id.toString()) { 160 | throw new GraphQLError("Can't update a different user", { 161 | extensions: { 162 | code: "FORBIDDEN", 163 | }, 164 | }); 165 | } 166 | // If password is changed, hash it since findAndUpdate doesn't call pre-save 167 | if (input.password) { 168 | input.password = await bcrypt.hash(input.password, 12); 169 | } 170 | const userDoc = await ctx.UserModel.findByIdAndUpdate(id, input, { new: true }); 171 | if (!userDoc) { 172 | throw new Error("No such user"); 173 | } 174 | return userDoc; 175 | } 176 | 177 | @Authorized([ROLE.SCOUTMASTER]) 178 | @Mutation(returns => User) 179 | async deleteUser( 180 | @Arg("id", type => ID) id: mongoose.Types.ObjectId, 181 | @Ctx() ctx: ContextType 182 | ): Promise { 183 | return await ctx.UserModel.findByIdAndDelete(id); 184 | } 185 | 186 | @Authorized() 187 | @Mutation(returns => User) 188 | async updateCurrUser( 189 | @Arg("input") input: UpdateUserInput, 190 | @Ctx() ctx: ContextType 191 | ): Promise { 192 | return await ctx.UserModel.findByIdAndUpdate(ctx.user!._id, input, { new: true }); 193 | } 194 | 195 | @Authorized() 196 | @Mutation(returns => Boolean) 197 | async dismissNotification( 198 | @Arg("id", type => ID) id: mongoose.Types.ObjectId, 199 | @Ctx() ctx: ContextType 200 | ): Promise { 201 | const notif = ctx.user!.unreadNotifications.id(id); 202 | if (notif === null) { 203 | return false; 204 | } 205 | notif.remove(); 206 | ctx.user!.save(); 207 | return true; 208 | } 209 | 210 | @Mutation() 211 | requestPasswordReset( 212 | @Arg("email") email: string, 213 | @Ctx() ctx: ContextType 214 | ): boolean { 215 | ctx.UserModel.findOne({email}).then((user) => { 216 | if (!user) { 217 | return; 218 | } 219 | ctx.TokenModel.create({ 220 | type: TOKEN_TYPE.PASS_RESET, 221 | user: user._id, 222 | token: crypto.randomUUID(), 223 | }).then((tok) => { 224 | EmailService.sendResetPasswordEmail(email, tok.token); 225 | }); 226 | }); 227 | 228 | return true; // Always return true 229 | } 230 | 231 | @Mutation(returns => User, {nullable: true}) 232 | async resetPassword( 233 | @Arg("input") input: ResetPasswordInput, 234 | @Ctx() ctx: ContextType 235 | ): Promise { 236 | const user = await ctx.UserModel.findOne({email: input.email}); 237 | if (!user) { 238 | return null; 239 | } 240 | const token = await ctx.TokenModel.findOne({user: {_id: user._id}, token: input.token, type: TOKEN_TYPE.PASS_RESET}); 241 | if (!token) { 242 | return null; 243 | } 244 | user.password = input.password; 245 | const ret = await user.save(); 246 | token.delete(); 247 | return ret; 248 | } 249 | 250 | @Authorized() 251 | @FieldResolver(returns => ROLE, {nullable: true}) 252 | currRole(@Root() user: User, @Ctx() ctx: ContextType): ROLE | undefined { 253 | if (user._id.equals(ctx.user!._id)) { 254 | return ctx.currMembership?.role; 255 | } 256 | } 257 | 258 | @Authorized() 259 | @FieldResolver(returns => Troop) 260 | async currTroop(@Root() user: User, @Ctx() ctx: ContextType): Promise { 261 | if (user._id.equals(ctx.user!._id) && ctx.currMembership) { 262 | return await ctx.TroopModel.findById(ctx.currMembership.troopID._id) ?? undefined; 263 | } 264 | } 265 | 266 | @Authorized() 267 | @FieldResolver(returns => Patrol) 268 | async currPatrol(@Root() user: User, @Ctx() ctx: ContextType): Promise { 269 | if (user._id.equals(ctx.user!._id) && ctx.currMembership) { 270 | const myTroop = await ctx.TroopModel.findById(ctx.currMembership.troopID._id); 271 | if (myTroop) { 272 | return myTroop.patrols.id(ctx.currMembership.patrolID) ?? undefined; 273 | } 274 | } 275 | } 276 | 277 | @Authorized() 278 | @FieldResolver(returns => [Membership]) 279 | otherGroups(@Ctx() ctx: ContextType): Membership[] { 280 | if (ctx.user && ctx.user.groups.length > 1) { 281 | return ctx.user.groups.reduce((mapped: Membership[], group) => { 282 | if (group._id && group._id.toString() !== ctx.membershipIDString) { 283 | mapped.push(group); 284 | } 285 | return mapped; 286 | }, []); 287 | } 288 | return []; 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /tests/resolvers/user.test.ts: -------------------------------------------------------------------------------- 1 | // Models 2 | import { ApolloServer, GraphQLResponse } from '@apollo/server'; 3 | import { DocumentType } from '@typegoose/typegoose'; 4 | import assert from 'assert'; 5 | import gql from 'graphql-tag'; 6 | import mongoose from 'mongoose'; 7 | import { buildSchemaSync } from 'type-graphql'; 8 | 9 | import { TokenModel, UserModel } from '../../models/models'; 10 | import { User } from '../../models/User'; 11 | import { TypegooseMiddleware } from '../../src/middleware/typegoose_middlware'; 12 | import { AuthResolver } from '../../src/resolvers/auth'; 13 | import { PatrolResolver } from '../../src/resolvers/patrol'; 14 | import { TroopResolver } from '../../src/resolvers/troop'; 15 | import { UserResolver } from '../../src/resolvers/user'; 16 | import * as authFns from '../../src/utils/Auth'; 17 | import createTestContext from '../utils/test_context'; 18 | import { setupDB } from '../utils/test_setup'; 19 | 20 | setupDB('scouttrek-test'); 21 | 22 | const schema = buildSchemaSync({ 23 | resolvers: [AuthResolver, UserResolver, TroopResolver, PatrolResolver], 24 | globalMiddlewares: [TypegooseMiddleware], 25 | authChecker: authFns.customAuthChecker, 26 | }); 27 | 28 | // Create GraphQL server 29 | const server = new ApolloServer({ 30 | schema, 31 | }); 32 | 33 | describe("User resolver", () => { 34 | let user: DocumentType; 35 | let otherUser: DocumentType; 36 | 37 | beforeEach(async () => { 38 | user = await UserModel.create({ 39 | name: "Test User", 40 | email: "test@example.com", 41 | password: "password", 42 | passwordConfirm: "password", 43 | phone: "1234567890", 44 | birthday: "2000-12-12" 45 | }); 46 | otherUser = await UserModel.create({ 47 | name: "Other User", 48 | email: "other@example.com", 49 | password: "otherpassword", 50 | passwordConfirm: "otherpassword", 51 | phone: "9876543210", 52 | birthday: "2000-01-12" 53 | }); 54 | }); 55 | 56 | describe("Update user", () => { 57 | let response: GraphQLResponse; 58 | 59 | it('should fail if user is not authenticated', async () => { 60 | const userID = user._id.toString(); 61 | const updateUser = { 62 | name: "Updated name" 63 | }; 64 | const updateUserQuery = gql` 65 | mutation updateUser($updateUser: UpdateUserInput!, $userID: ID!) { 66 | updateUser(input: $updateUser, id: $userID) { 67 | id 68 | name 69 | } 70 | } 71 | `; 72 | 73 | response = await server.executeOperation({ 74 | query: updateUserQuery, 75 | variables: {updateUser, userID} 76 | }, { 77 | contextValue: await createTestContext() 78 | }); 79 | 80 | assert(response.body.kind === "single"); 81 | expect(response.body.singleResult.errors).toBeDefined(); 82 | expect(response.body.singleResult.errors).toHaveLength(1); 83 | expect(response.body.singleResult.errors![0]?.extensions?.code).toBe("UNAUTHORIZED"); 84 | expect(response.body.singleResult.errors![0]?.message).toBe("Not authorized!"); 85 | }); 86 | 87 | it('should fail if user tries to edit another user', async () => { 88 | const userID = user._id.toString(); 89 | const otherUserID = otherUser._id.toString(); 90 | const updateUser = { 91 | name: "Updated name" 92 | }; 93 | const updateUserQuery = gql` 94 | mutation updateUser($updateUser: UpdateUserInput!, $userID: ID!) { 95 | updateUser(input: $updateUser, id: $userID) { 96 | id 97 | name 98 | } 99 | } 100 | `; 101 | 102 | response = await server.executeOperation( 103 | { 104 | query: updateUserQuery, 105 | variables: {updateUser, userID: otherUserID} 106 | }, { 107 | contextValue: await createTestContext(new mongoose.Types.ObjectId(userID)), 108 | } 109 | ); 110 | 111 | assert(response.body.kind === "single"); 112 | expect(response.body.singleResult.errors).toBeDefined(); 113 | expect(response.body.singleResult.errors).toHaveLength(1); 114 | expect(response.body.singleResult.errors![0]?.extensions?.code).toBe("FORBIDDEN"); 115 | expect(response.body.singleResult.errors![0]?.message).toBe("Can't update a different user"); 116 | }); 117 | 118 | describe('Without password', () => { 119 | beforeEach(async () => { 120 | const userID = user._id.toString(); 121 | const birthday = new Date(Date.now()); 122 | const updateUser = { 123 | name: "Updated name", 124 | email: "newemail@example.com", 125 | phone: "9876543210", 126 | birthday: birthday.toDateString(), 127 | }; 128 | const updateUserQuery = gql` 129 | mutation updateUser($updateUser: UpdateUserInput!, $userID: ID!) { 130 | updateUser(input: $updateUser, id: $userID) { 131 | id 132 | name 133 | email 134 | phone 135 | birthday 136 | } 137 | } 138 | `; 139 | 140 | response = await server.executeOperation( 141 | { 142 | query: updateUserQuery, 143 | variables: {updateUser, userID} 144 | }, { 145 | contextValue: await createTestContext(new mongoose.Types.ObjectId(userID)) 146 | } 147 | ); 148 | }); 149 | 150 | it('should not have any errors', () => { 151 | assert(response.body.kind === "single"); 152 | expect(response.body.singleResult.errors).toBeUndefined(); 153 | }); 154 | 155 | it('should return updated information', () => { 156 | assert(response.body.kind === "single"); 157 | expect(response.body.singleResult.data).toBeDefined(); 158 | const updateUser = response.body.singleResult.data!.updateUser as any; // Necessary because of renaming _id to id 159 | expect(updateUser.id).toEqual(user._id.toString()); 160 | expect(updateUser.name).toBe("Updated name"); 161 | }); 162 | 163 | it('should update the user in the database', async () => { 164 | const updatedUser = await UserModel.findById(user._id); 165 | expect(updatedUser).not.toBeNull(); 166 | expect(updatedUser!.name).toBe("Updated name"); 167 | }); 168 | }); 169 | 170 | describe('With password', () => { 171 | beforeEach(async () => { 172 | const userID = user._id.toString(); 173 | const updateUser = { 174 | name: "Updated name", 175 | password: "newpassword", 176 | }; 177 | const updateUserQuery = gql` 178 | mutation updateUser($updateUser: UpdateUserInput!, $userID: ID!) { 179 | updateUser(input: $updateUser, id: $userID) { 180 | id 181 | name 182 | } 183 | } 184 | `; 185 | 186 | response = await server.executeOperation( 187 | { 188 | query: updateUserQuery, 189 | variables: {updateUser, userID} 190 | }, 191 | { 192 | contextValue: await createTestContext(new mongoose.Types.ObjectId(userID)) 193 | } 194 | ); 195 | }); 196 | 197 | it('should not have any errors', () => { 198 | assert(response.body.kind === "single"); 199 | expect(response.body.singleResult.errors).toBeUndefined(); 200 | }); 201 | 202 | it('should return updated information', () => { 203 | assert(response.body.kind === "single"); 204 | expect(response.body.singleResult.data).toBeDefined(); 205 | const updateUser = response.body.singleResult.data!.updateUser as any; // Necessary because of renaming _id to id 206 | expect(updateUser.id).toEqual(user._id.toString()); 207 | expect(updateUser.name).toBe("Updated name"); 208 | }); 209 | 210 | it('should update the user in the database', async () => { 211 | const updatedUser = await UserModel.findById(user._id); 212 | expect(updatedUser).not.toBeNull(); 213 | expect(updatedUser!.name).toBe("Updated name"); 214 | expect(!updatedUser!.isValidPassword("password")); 215 | expect(updatedUser!.isValidPassword("newpassword")); 216 | }); 217 | }); 218 | }); 219 | 220 | describe("Request password reset", () => { 221 | let response: GraphQLResponse; 222 | 223 | it('should do nothing if email does not exist', async () => { 224 | const requestPasswordResetQuery = gql` 225 | mutation requestPasswordReset($email: String!) { 226 | requestPasswordReset(email: $email) 227 | } 228 | `; 229 | 230 | response = await server.executeOperation({ 231 | query: requestPasswordResetQuery, 232 | variables: {email: "doesnt@exist.com"} 233 | }, { 234 | contextValue: await createTestContext() 235 | }); 236 | 237 | assert(response.body.kind === "single"); 238 | expect(response.body.singleResult.errors).toBeUndefined(); 239 | expect(response.body.singleResult.data!.requestPasswordReset).toBe(true); 240 | expect(await TokenModel.count()).toBe(0); 241 | }); 242 | 243 | // TODO: Should we mock the email service even though this is an end-to-end test? 244 | }); 245 | }); 246 | -------------------------------------------------------------------------------- /src/resolvers/event.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError, GraphQLScalarType } from 'graphql'; 2 | import mongoose from 'mongoose'; 3 | import { 4 | Arg, 5 | Authorized, 6 | Ctx, 7 | Field, 8 | FieldResolver, 9 | Float, 10 | ID, 11 | InputType, 12 | Int, 13 | Mutation, 14 | Query, 15 | Resolver, 16 | Root, 17 | } from 'type-graphql'; 18 | 19 | import { Event, EVENT_TYPE } from '../../models/Event'; 20 | import { Roster } from '../../models/Roster'; 21 | import { Location, Patrol, Troop } from '../../models/TroopAndPatrol'; 22 | import { User } from '../../models/User'; 23 | import EventSchemas from '../Event/EventSchemas.json'; 24 | import { sendNotifications } from '../notifications'; 25 | import { getDocument, getDocuments } from '../utils/mongoose'; 26 | 27 | import type { ContextType } from '../context'; 28 | import { Ref } from '@typegoose/typegoose'; 29 | 30 | @InputType() 31 | class AddRosterInput { 32 | @Field(type => [ID]) 33 | yes!: mongoose.Types.ObjectId[]; 34 | @Field(type => [ID]) 35 | no!: mongoose.Types.ObjectId[]; 36 | @Field(type => [ID]) 37 | maybe!: mongoose.Types.ObjectId[]; 38 | } 39 | 40 | @InputType() 41 | class UpdateLocationInput { 42 | // if this is the same as the imported location type, can we remove? 43 | @Field(type => Float) 44 | lat: number; 45 | @Field(type => Float) 46 | lng: number; 47 | @Field({ nullable: true }) 48 | address?: string; 49 | } 50 | 51 | @InputType() 52 | class AddEventInput { 53 | @Field(type => EVENT_TYPE) 54 | type!: EVENT_TYPE; 55 | @Field({ nullable: true }) 56 | title?: string; 57 | @Field({ nullable: true }) 58 | description?: string; 59 | @Field(type => Date, { nullable: true }) 60 | date?: Date; 61 | @Field({ nullable: true }) 62 | startTime?: string; 63 | @Field(type => Date, { nullable: true }) 64 | meetTime?: Date; 65 | @Field(type => Date, { nullable: true }) 66 | leaveTime?: Date; 67 | @Field({ nullable: true }) 68 | endTime?: string; 69 | @Field(type => Date, { nullable: true }) 70 | endDate?: Date; 71 | @Field(type => Date, { nullable: true }) 72 | pickupTime?: Date; 73 | @Field({ nullable: true }) 74 | uniqueMeetLocation?: string; 75 | @Field(type => UpdateLocationInput, { nullable: true }) 76 | location?: UpdateLocationInput; 77 | @Field(type => UpdateLocationInput, { nullable: true }) 78 | meetLocation?: UpdateLocationInput; 79 | @Field(type => Date, { nullable: true }) 80 | checkoutTime?: Date; 81 | @Field(type => Int, { nullable: true }) 82 | distance?: number; 83 | @Field(type => Boolean, { nullable: true }) 84 | published?: boolean; 85 | } 86 | 87 | @InputType() 88 | class UpdateEventInput { 89 | @Field(type => ID, { nullable: true }) 90 | creator?: mongoose.Types.ObjectId; 91 | @Field(type => EVENT_TYPE, { nullable: true }) 92 | type?: EVENT_TYPE; 93 | @Field(type => AddRosterInput, { nullable: true }) 94 | attending?: AddRosterInput; 95 | @Field({ nullable: true }) 96 | title?: string; 97 | @Field({ nullable: true }) 98 | description?: string; 99 | @Field(type => Date, { nullable: true }) 100 | date?: Date; 101 | @Field({ nullable: true }) 102 | startTime?: string; 103 | @Field(type => Date, { nullable: true }) 104 | meetTime?: Date; 105 | @Field(type => Date, { nullable: true }) 106 | leaveTime?: Date; 107 | @Field({ nullable: true }) 108 | endTime?: string; 109 | @Field(type => Date, { nullable: true }) 110 | endDate?: Date; 111 | @Field(type => Date, { nullable: true }) 112 | pickupTime?: Date; 113 | @Field({ nullable: true }) 114 | uniqueMeetLocation?: string; 115 | @Field(type => UpdateLocationInput, { nullable: true }) 116 | location?: UpdateLocationInput; 117 | @Field(type => UpdateLocationInput, { nullable: true }) 118 | meetLocation?: UpdateLocationInput; 119 | @Field(type => Date, { nullable: true }) 120 | checkoutTime?: Date; 121 | @Field(type => Int, { nullable: true }) 122 | distance?: number; 123 | @Field(type => Boolean, { nullable: true }) 124 | published?: boolean; 125 | } 126 | 127 | // custom scalar type so that we can query for event schemas without throwing an error 128 | // this is the barest of bare-bone implementations but it works 129 | const EventSchemaScalar = new GraphQLScalarType({ 130 | name: 'EventSchemaType', 131 | description: 'scalar for event schema', 132 | serialize(value: Object) { 133 | return value; 134 | }, 135 | parseValue(value: Object) { 136 | return value; 137 | }, 138 | parseLiteral(value) { 139 | return value; 140 | }, 141 | }); 142 | 143 | @Resolver(of => Event) 144 | export class EventResolver { 145 | // what is the json in resolvers??? 146 | @Authorized() 147 | @Query(returns => Event) 148 | async event( 149 | @Arg('id', type => ID) id: string, 150 | @Ctx() ctx: ContextType 151 | ): Promise { 152 | const event = await ctx.EventModel.findById(id); 153 | if (!event) { 154 | throw new Error('Event could not be found'); 155 | } 156 | return event; 157 | } 158 | 159 | @Authorized() 160 | @Query(returns => [Event]) 161 | async events( 162 | @Arg('first', type => Int, { nullable: true }) first: number, 163 | @Arg('skip', type => Int, { nullable: true }) skip: number, 164 | @Ctx() ctx: ContextType 165 | ): Promise { 166 | if (ctx.currMembership === undefined) { 167 | throw new Error('No membership selected!'); 168 | } 169 | const myTroop = await ctx.TroopModel.findById( 170 | ctx.currMembership.troopID._id 171 | ); 172 | if (myTroop === null) { 173 | throw new Error('Selected troop does not exist'); 174 | } 175 | 176 | const events = await ctx.EventModel.find( 177 | { 178 | date: { 179 | $gte: new Date(Date.now() - 86400000 * 1.5), 180 | $lte: new Date(Date.now() + 6.04e8 * 10), 181 | }, 182 | troop: myTroop, 183 | }, 184 | null, 185 | { 186 | first, 187 | skip, 188 | } 189 | ).sort({ date: 1 }); 190 | 191 | return events; 192 | } 193 | 194 | @Query(returns => EventSchemaScalar) 195 | eventSchemas(): Object { 196 | // TODO: fix return type once jaron is done correcting the type 197 | return EventSchemas; 198 | } 199 | 200 | @Authorized() 201 | @Mutation(returns => Boolean) 202 | async deleteEvent( 203 | @Arg('id', type => ID) id: string, 204 | @Ctx() ctx: ContextType 205 | ): Promise { 206 | await ctx.EventModel.findByIdAndDelete(id); 207 | return true; 208 | } 209 | 210 | @Authorized() 211 | @Mutation(returns => Boolean) 212 | async rsvp( 213 | @Arg('event_id', type => ID) eventID: string, 214 | @Arg('response', type => Number) response: number, 215 | @Ctx() ctx: ContextType 216 | ): Promise { 217 | if (ctx.currMembership === undefined) { 218 | throw new Error('No membership selected!'); 219 | } 220 | const myTroop = await ctx.TroopModel.findById( 221 | ctx.currMembership.troopID._id 222 | ); 223 | if (myTroop === null) { 224 | throw new Error('Selected troop does not exist'); 225 | } 226 | 227 | const event = await ctx.EventModel.findById(eventID); 228 | if (!event) { 229 | throw new Error('Event does not exist'); 230 | } 231 | 232 | if (!event.troop._id.equals(myTroop._id)) { 233 | throw new GraphQLError('Forbidden', { 234 | extensions: { 235 | code: 'FORBIDDEN', 236 | }, 237 | }); 238 | } 239 | 240 | const userID = ctx.user!._id; 241 | if (response === 0) 242 | await ctx.EventModel.updateOne( 243 | { _id: eventID }, 244 | { 245 | $pull: { 'roster.yes': userID, 'roster.maybe': userID }, 246 | $addToSet: { 'roster.no': userID }, 247 | } 248 | ); 249 | else if (response === 1) 250 | await ctx.EventModel.updateOne( 251 | { _id: eventID }, 252 | { 253 | $pull: { 'roster.no': userID, 'roster.maybe': userID }, 254 | $addToSet: { 'roster.yes': userID }, 255 | } 256 | ); 257 | else if (response === 2) 258 | await ctx.EventModel.updateOne( 259 | { _id: eventID }, 260 | { 261 | $pull: { 'roster.yes': userID, 'roster.no': userID }, 262 | $addToSet: { 'roster.maybe': userID }, 263 | } 264 | ); 265 | else 266 | throw new GraphQLError('Invalid RSVP', { 267 | extensions: { 268 | code: 'BAD_REQUEST', 269 | }, 270 | }); 271 | 272 | return true; 273 | } 274 | 275 | @Authorized() 276 | @Mutation(returns => Event) 277 | async addEvent( 278 | @Arg('input') input: AddEventInput, 279 | @Ctx() ctx: ContextType 280 | ): Promise { 281 | if (ctx.currMembership === undefined) { 282 | throw new Error('No membership selected!'); 283 | } 284 | 285 | if (input.type === 'TROOP_MEETING') { 286 | input.title = 'Troop Meeting'; 287 | } 288 | 289 | // what is the difference between meetTime and startTime ????? 290 | const startTime = input?.meetTime || input?.startTime; 291 | let startDatetime = new Date(startTime ?? Date.now()); 292 | const eventDate = new Date(input?.date ?? Date.now()); 293 | startDatetime.setMonth(eventDate.getMonth()); 294 | startDatetime.setDate(eventDate.getDate()); 295 | 296 | const { location, meetLocation, ...restInput } = input; 297 | const mutationObject: Partial = { 298 | ...restInput, 299 | troop: ctx.currMembership.troopID, 300 | patrol: ctx.currMembership.patrolID, 301 | creator: ctx.user!._id, 302 | notification: new Date(startDatetime.valueOf() - 86400000), 303 | }; 304 | 305 | if (location) { 306 | mutationObject.locationPoint = { 307 | type: 'Point', 308 | coordinates: [location.lng, location.lat], 309 | address: location.address, 310 | }; 311 | } 312 | if (meetLocation) { 313 | mutationObject.meetLocationPoint = { 314 | type: 'Point', 315 | coordinates: [meetLocation.lng, meetLocation.lat], 316 | address: meetLocation.address, 317 | }; 318 | } 319 | 320 | const event = await ctx.EventModel.create(mutationObject); 321 | sendNotifications( 322 | ctx.tokens ?? [], 323 | `${input.title} event has been created. See details.`, 324 | { 325 | type: 'event', 326 | eventType: event.type, 327 | ID: event.id, 328 | } 329 | ); 330 | return event; 331 | } 332 | 333 | @Authorized() 334 | @Mutation(returns => Event) 335 | async updateEvent( 336 | @Arg('input') input: UpdateEventInput, 337 | @Arg('id', type => ID) id: string, 338 | @Ctx() ctx: ContextType 339 | ): Promise { 340 | const { location, meetLocation, ...restInput } = input; 341 | const newVals: Partial = { ...restInput }; 342 | if (location) { 343 | newVals.locationPoint = { 344 | type: 'Point', 345 | coordinates: [location.lng, location.lat], 346 | address: location.address, 347 | }; 348 | } 349 | if (meetLocation) { 350 | newVals.meetLocationPoint = { 351 | type: 'Point', 352 | coordinates: [meetLocation.lng, meetLocation.lat], 353 | address: meetLocation.address, 354 | }; 355 | } 356 | await ctx.EventModel.updateOne({ _id: id }, newVals); 357 | 358 | const updatedEvent = await ctx.EventModel.findById(id); 359 | 360 | sendNotifications( 361 | ctx.tokens ?? [], 362 | `${updatedEvent?.title} event has been updated!`, 363 | { 364 | type: 'event', 365 | eventType: updatedEvent?.type ?? '', 366 | ID: updatedEvent?.id ?? '', 367 | } 368 | ); 369 | return updatedEvent; 370 | } 371 | 372 | @FieldResolver(returns => Troop) 373 | async troop( 374 | @Root() event: Event, 375 | @Ctx() ctx: ContextType 376 | ): Promise { 377 | return (await ctx.TroopModel.findById(event.troop._id)) ?? undefined; 378 | } 379 | 380 | @FieldResolver(returns => Patrol) 381 | async patrol( 382 | @Root() event: Event, 383 | @Ctx() ctx: ContextType 384 | ): Promise { 385 | const troop = await ctx.TroopModel.findById(event.troop._id); 386 | const patrol = await troop?.patrols?.id(event.patrol); 387 | return patrol ?? undefined; 388 | } 389 | 390 | @FieldResolver(returns => User) 391 | async creator( 392 | @Root() event: Event, 393 | @Ctx() ctx: ContextType 394 | ): Promise { 395 | return (await ctx.UserModel.findById(event.creator?._id)) ?? undefined; 396 | } 397 | 398 | @FieldResolver(returns => Location, { nullable: true }) 399 | location(@Root() event: Event): Location | null { 400 | if (event.locationPoint && event.locationPoint.coordinates.length == 2) { 401 | return { 402 | lng: event.locationPoint.coordinates[0]!, 403 | lat: event.locationPoint.coordinates[1]!, 404 | address: event.locationPoint.address, 405 | }; 406 | } 407 | return null; 408 | } 409 | 410 | @FieldResolver(returns => Location, { nullable: true }) 411 | meetLocation(@Root() event: Event): Location | null { 412 | if ( 413 | event.meetLocationPoint && 414 | event.meetLocationPoint.coordinates.length == 2 415 | ) { 416 | return { 417 | lng: event.meetLocationPoint.coordinates[0]!, 418 | lat: event.meetLocationPoint.coordinates[1]!, 419 | address: event.meetLocationPoint.address, 420 | }; 421 | } 422 | return null; 423 | } 424 | } 425 | 426 | @Resolver(of => Roster) 427 | export class RosterResolver { 428 | @FieldResolver(returns => [User]) 429 | async yes(@Root() roster: Roster, @Ctx() ctx: ContextType): Promise { 430 | const event = await ctx.EventModel.findById(roster.eventId); 431 | if (!event) { 432 | throw new GraphQLError('No such event', { 433 | extensions: { 434 | code: 'NOT_FOUND', 435 | }, 436 | }); 437 | } 438 | return getDocuments((await event.populate('roster.yes')).roster.yes); 439 | } 440 | 441 | @FieldResolver(returns => [User]) 442 | async no(@Root() roster: Roster, @Ctx() ctx: ContextType): Promise { 443 | const event = await ctx.EventModel.findById(roster.eventId); 444 | if (!event) { 445 | throw new GraphQLError('No such event', { 446 | extensions: { 447 | code: 'NOT_FOUND', 448 | }, 449 | }); 450 | } 451 | return getDocuments((await event.populate('roster.no')).roster.no); 452 | } 453 | 454 | @FieldResolver(returns => [User]) 455 | async maybe( 456 | @Root() roster: Roster, 457 | @Ctx() ctx: ContextType 458 | ): Promise { 459 | const event = await ctx.EventModel.findById(roster.eventId); 460 | if (!event) { 461 | throw new GraphQLError('No such event', { 462 | extensions: { 463 | code: 'NOT_FOUND', 464 | }, 465 | }); 466 | } 467 | return getDocuments((await event.populate('roster.maybe')).roster.maybe); 468 | } 469 | 470 | @FieldResolver(returns => [User]) 471 | async noResponse( 472 | @Root() roster: Roster, 473 | @Ctx() ctx: ContextType 474 | ): Promise { 475 | const event = await ctx.EventModel.findById(roster.eventId); 476 | if (!event) { 477 | throw new GraphQLError('No such event', { 478 | extensions: { 479 | code: 'NOT_FOUND', 480 | }, 481 | }); 482 | } 483 | await event.populate('troop'); 484 | const troop = getDocument(event.troop); 485 | const invited = new Set( 486 | troop.patrols.flatMap(p => p.members.map(u => u._id.toString())) 487 | ); 488 | const responded = new Set( 489 | roster.yes 490 | .concat(roster.no) 491 | .concat(roster.maybe) 492 | .map(u => u._id.toString()) 493 | ); 494 | const notResponded = [...invited].filter(u => !responded.has(u)); 495 | return await ctx.UserModel.find({ _id: { $in: notResponded } }); 496 | } 497 | } 498 | -------------------------------------------------------------------------------- /src/Event/EventSchemas.json: -------------------------------------------------------------------------------- 1 | { 2 | "hike": { 3 | "metaData": { 4 | "eventID": "HIKE", 5 | "subtitle": "Plan a visit to the trails.", 6 | "image": { 7 | "uri": "https://res.cloudinary.com/wow-your-client/image/upload/c_scale,w_550/v1582556921/ScoutTrek/hiking_trip.png" 8 | } 9 | }, 10 | "options": [ 11 | { 12 | "condition": "uniqueMeetLocation", 13 | "hidden": "The trail.", 14 | "shown": "A different meet point.", 15 | "hiddenFields": [ 16 | "meetLocation", 17 | "meetTime", 18 | "leaveTime" 19 | ] 20 | } 21 | ], 22 | "form": [ 23 | { 24 | "fieldType": "shortText", 25 | "fieldID": "title", 26 | "title": "Hike Title", 27 | "questionText": "What do you want to call your hike?" 28 | }, 29 | { 30 | "fieldType": "location", 31 | "fieldID": "location", 32 | "title": "Location", 33 | "questionText": "Where do you want to hike?" 34 | }, 35 | { 36 | "fieldType": "setting", 37 | "fieldID": "uniqueMeetLocation", 38 | "questionText": "Where do you want everyone to meet?", 39 | "payload": { 40 | "options": [ 41 | "The trail.", 42 | "A different meet point." 43 | ] 44 | } 45 | }, 46 | { 47 | "fieldType": "date", 48 | "fieldID": "date", 49 | "title": "Date", 50 | "questionText": "When is your hike?" 51 | }, 52 | { 53 | "fieldType": "time", 54 | "fieldID": "startTime", 55 | "title": "Time", 56 | "questionText": "What time should everybody be at the trailhead?" 57 | }, 58 | { 59 | "fieldType": "location", 60 | "fieldID": "meetLocation", 61 | "title": "Meet Location", 62 | "questionText": "Where should everyone meet?" 63 | }, 64 | { 65 | "fieldType": "time", 66 | "fieldID": "meetTime", 67 | "title": "Meet Time", 68 | "questionText": "What time should everybody get to your meet place?" 69 | }, 70 | { 71 | "fieldType": "time", 72 | "fieldID": "leaveTime", 73 | "title": "Leave Time", 74 | "questionText": "What time do you plan to leave your meet place?" 75 | }, 76 | { 77 | "fieldType": "time", 78 | "fieldID": "endTime", 79 | "title": "End Time", 80 | "questionText": "Around what time will you return from the hike?" 81 | }, 82 | { 83 | "fieldType": "slider", 84 | "fieldID": "distance", 85 | "title": "Distance", 86 | "payload": { 87 | "units": "Miles", 88 | "min": 1, 89 | "max": 26 90 | }, 91 | "questionText": "Hike Distance (in miles)?" 92 | }, 93 | { 94 | "fieldType": "description", 95 | "fieldID": "description", 96 | "title": "Description", 97 | "questionText": "What additional information do you want everyone to know about this hike?" 98 | } 99 | ] 100 | }, 101 | "campout": { 102 | "metaData": { 103 | "eventID": "CAMPOUT", 104 | "subtitle": "A few days in the wild outdoors.", 105 | "image": { 106 | "uri": "https://res.cloudinary.com/wow-your-client/image/upload/c_scale,e_contrast:-25,w_600/v1595086345/ScoutTrek/campfire.png" 107 | } 108 | }, 109 | "options": [ 110 | { 111 | "condition": "uniqueMeetLocation", 112 | "hidden": "The campground.", 113 | "shown": "A different meet point.", 114 | "hiddenFields": [ 115 | "meetLocation", 116 | "meetTime", 117 | "leaveTime" 118 | ] 119 | } 120 | ], 121 | "form": [ 122 | { 123 | "fieldType": "shortText", 124 | "fieldID": "title", 125 | "title": "Campout Title", 126 | "questionText": "What do you want to call your campout?" 127 | }, 128 | { 129 | "fieldType": "location", 130 | "fieldID": "location", 131 | "title": "Location", 132 | "questionText": "Where are you camping?" 133 | }, 134 | { 135 | "fieldType": "setting", 136 | "fieldID": "uniqueMeetLocation", 137 | "questionText": "Where do you want everyone to meet?", 138 | "payload": { 139 | "options": [ 140 | "The campground.", 141 | "A different meet point." 142 | ] 143 | } 144 | }, 145 | { 146 | "fieldType": "date", 147 | "fieldID": "date", 148 | "title": "Date", 149 | "questionText": "What day does your campout begin?" 150 | }, 151 | { 152 | "fieldType": "time", 153 | "fieldID": "startTime", 154 | "title": "Time", 155 | "questionText": "What time do you want to be at the campground?" 156 | }, 157 | { 158 | "fieldType": "location", 159 | "fieldID": "meetLocation", 160 | "title": "Meet Location", 161 | "questionText": "Where should everyone meet?" 162 | }, 163 | { 164 | "fieldType": "time", 165 | "fieldID": "meetTime", 166 | "title": "Meet Time", 167 | "questionText": "What time should everybody get to your meet place?" 168 | }, 169 | { 170 | "fieldType": "time", 171 | "fieldID": "leaveTime", 172 | "title": "Leave Time", 173 | "questionText": "What time do you plan to leave your meet place?" 174 | }, 175 | { 176 | "fieldType": "time", 177 | "fieldID": "endTime", 178 | "title": "End Time", 179 | "questionText": "Around what time will you return from the campout?" 180 | }, 181 | { 182 | "fieldType": "description", 183 | "fieldID": "description", 184 | "title": "Description", 185 | "questionText": "What additional information do you want everyone to know about this campout?" 186 | } 187 | ] 188 | }, 189 | "summer_camp": { 190 | "metaData": { 191 | "eventID": "SUMMER_CAMP", 192 | "subtitle": "A week of non-stop fun.", 193 | "image": { 194 | "uri": "https://res.cloudinary.com/wow-your-client/image/upload/c_scale,w_600/v1590852981/ScoutTrek/ben-white-pV5ckb2HEVk-unsplash.jpg" 195 | } 196 | }, 197 | "options": [ 198 | { 199 | "condition": "uniqueMeetLocation", 200 | "hidden": "At camp.", 201 | "shown": "A different meet point.", 202 | "hiddenFields": [ 203 | "meetLocation", 204 | "meetTime", 205 | "leaveTime" 206 | ] 207 | } 208 | ], 209 | "form": [ 210 | { 211 | "fieldType": "shortText", 212 | "fieldID": "title", 213 | "title": "Summer Camp Name", 214 | "questionText": "What is your summer camp called?" 215 | }, 216 | { 217 | "fieldType": "location", 218 | "fieldID": "location", 219 | "title": "Location", 220 | "questionText": "Where are you camping?" 221 | }, 222 | { 223 | "fieldType": "setting", 224 | "fieldID": "uniqueMeetLocation", 225 | "questionText": "Where should Scouts get dropped off?", 226 | "payload": { 227 | "options": [ 228 | "At camp.", 229 | "A different meet point." 230 | ] 231 | } 232 | }, 233 | { 234 | "fieldType": "date", 235 | "fieldID": "date", 236 | "title": "Start Date", 237 | "questionText": "What day does your summer camp start?" 238 | }, 239 | { 240 | "fieldType": "time", 241 | "fieldID": "startTime", 242 | "title": "Start Time", 243 | "questionText": "What time should Scouts be arriving at camp?" 244 | }, 245 | { 246 | "fieldType": "location", 247 | "fieldID": "meetLocation", 248 | "title": "Meet Location", 249 | "questionText": "Where should everyone meet?" 250 | }, 251 | { 252 | "fieldType": "time", 253 | "fieldID": "meetTime", 254 | "title": "Meet Time", 255 | "questionText": "What time should everybody get to your meet place?" 256 | }, 257 | { 258 | "fieldType": "time", 259 | "fieldID": "leaveTime", 260 | "title": "Leave Time", 261 | "questionText": "What time do you plan to leave your meet place?" 262 | }, 263 | { 264 | "fieldType": "date", 265 | "fieldID": "endDate", 266 | "title": "End Date", 267 | "questionText": "What day will Scouts check out of camp?" 268 | }, 269 | { 270 | "fieldType": "time", 271 | "fieldID": "endTime", 272 | "title": "End Time", 273 | "questionText": "Around what time will activities be finished?" 274 | }, 275 | { 276 | "fieldType": "time", 277 | "fieldID": "pickupTime", 278 | "title": "Checkout Time", 279 | "questionText": "Around what time should Scouts get picked up?" 280 | }, 281 | { 282 | "fieldType": "description", 283 | "fieldID": "description", 284 | "title": "Description", 285 | "questionText": "What additional information do you want everyone to know about this campout?" 286 | } 287 | ] 288 | }, 289 | "troop_meeting": { 290 | "metaData": { 291 | "eventID": "TROOP_MEETING", 292 | "subtitle": "Plan out your activities for the week.", 293 | "image": { 294 | "uri": "https://res.cloudinary.com/wow-your-client/image/upload/c_scale,w_600/v1582557266/ScoutTrek/ScoutBadgesImage.jpg" 295 | } 296 | }, 297 | "form": [ 298 | { 299 | "fieldType": "location", 300 | "fieldID": "location", 301 | "title": "Location", 302 | "questionText": "Where will your Troop meeting take place?" 303 | }, 304 | { 305 | "fieldType": "date", 306 | "fieldID": "date", 307 | "title": "Date", 308 | "questionText": "When is the meeting?" 309 | }, 310 | { 311 | "fieldType": "time", 312 | "fieldID": "startTime", 313 | "title": "Time", 314 | "questionText": "What time does the meeting start?" 315 | }, 316 | { 317 | "fieldType": "time", 318 | "fieldID": "endTime", 319 | "title": "End Time", 320 | "questionText": "Around when will the event be finished?" 321 | }, 322 | { 323 | "fieldType": "description", 324 | "fieldID": "description", 325 | "title": "Description", 326 | "questionText": "What additional information do you want everyone to know about this event?" 327 | } 328 | ] 329 | }, 330 | "bike_ride": { 331 | "metaData": { 332 | "eventID": "BIKE_RIDE", 333 | "subtitle": "Feel the wind in your face.", 334 | "image": { 335 | "uri": "https://res.cloudinary.com/wow-your-client/image/upload/c_scale,w_600/v1599596052/ScoutTrek/bikeride.jpg" 336 | } 337 | }, 338 | "options": [ 339 | { 340 | "condition": "uniqueMeetLocation", 341 | "hidden": "At the bike trail.", 342 | "shown": "A different meet point.", 343 | "hiddenFields": [ 344 | "meetLocation", 345 | "meetTime", 346 | "leaveTime" 347 | ] 348 | } 349 | ], 350 | "form": [ 351 | { 352 | "fieldType": "shortText", 353 | "fieldID": "title", 354 | "title": "Bike Ride Title", 355 | "questionText": "What do you want to call this bike ride?" 356 | }, 357 | { 358 | "fieldType": "location", 359 | "fieldID": "location", 360 | "title": "Location", 361 | "questionText": "Where will this event be?" 362 | }, 363 | { 364 | "fieldType": "setting", 365 | "fieldID": "uniqueMeetLocation", 366 | "questionText": "Where do you want everyone to meet?", 367 | "payload": { 368 | "options": [ 369 | "The event bike trail.", 370 | "A different meet point." 371 | ] 372 | } 373 | }, 374 | { 375 | "fieldType": "date", 376 | "fieldID": "date", 377 | "title": "Date", 378 | "questionText": "When is this bike ride?" 379 | }, 380 | { 381 | "fieldType": "time", 382 | "fieldID": "startTime", 383 | "title": "Time", 384 | "questionText": "What time does the event start?" 385 | }, 386 | { 387 | "fieldType": "location", 388 | "fieldID": "meetLocation", 389 | "title": "Meet Location", 390 | "questionText": "Where should everyone meet?" 391 | }, 392 | { 393 | "fieldType": "time", 394 | "fieldID": "meetTime", 395 | "title": "Meet Time", 396 | "questionText": "What time should everybody get to your meet place?" 397 | }, 398 | { 399 | "fieldType": "time", 400 | "fieldID": "leaveTime", 401 | "title": "Leave Time", 402 | "questionText": "What time do you plan to leave your meet place?" 403 | }, 404 | { 405 | "fieldType": "time", 406 | "fieldID": "endTime", 407 | "title": "End Time", 408 | "questionText": "Around when will the bike ride be finished?" 409 | }, 410 | { 411 | "fieldType": "description", 412 | "fieldID": "description", 413 | "title": "Description", 414 | "questionText": "What additional information do you want everyone to know about this bike ride?" 415 | } 416 | ] 417 | }, 418 | "canoe_trip": { 419 | "metaData": { 420 | "eventID": "CANOE_TRIP", 421 | "subtitle": "On the water, in style.", 422 | "image": { 423 | "uri": "https://res.cloudinary.com/wow-your-client/image/upload/c_scale,w_600/v1599596674/ScoutTrek/canoeing.jpg" 424 | } 425 | }, 426 | "options": [ 427 | { 428 | "condition": "uniqueMeetLocation", 429 | "hidden": "At the water.", 430 | "shown": "A different meet point.", 431 | "hiddenFields": [ 432 | "meetLocation", 433 | "meetTime", 434 | "leaveTime" 435 | ] 436 | } 437 | ], 438 | "form": [ 439 | { 440 | "fieldType": "shortText", 441 | "fieldID": "title", 442 | "title": "Canoe Trip Title", 443 | "questionText": "What do you want to call this canoe trip?" 444 | }, 445 | { 446 | "fieldType": "location", 447 | "fieldID": "location", 448 | "title": "Location", 449 | "questionText": "Where will this canoe trip be?" 450 | }, 451 | { 452 | "fieldType": "setting", 453 | "fieldID": "uniqueMeetLocation", 454 | "questionText": "Where do you want everyone to meet?", 455 | "payload": { 456 | "options": [ 457 | "At the water.", 458 | "A different meet point." 459 | ] 460 | } 461 | }, 462 | { 463 | "fieldType": "date", 464 | "fieldID": "date", 465 | "title": "Date", 466 | "questionText": "When is this canoe trip?" 467 | }, 468 | { 469 | "fieldType": "time", 470 | "fieldID": "startTime", 471 | "title": "Time", 472 | "questionText": "What time does the event start?" 473 | }, 474 | { 475 | "fieldType": "location", 476 | "fieldID": "meetLocation", 477 | "title": "Meet Location", 478 | "questionText": "Where should everyone meet?" 479 | }, 480 | { 481 | "fieldType": "time", 482 | "fieldID": "meetTime", 483 | "title": "Meet Time", 484 | "questionText": "What time should everybody get to your meet place?" 485 | }, 486 | { 487 | "fieldType": "time", 488 | "fieldID": "leaveTime", 489 | "title": "Leave Time", 490 | "questionText": "What time do you plan to leave your meet place?" 491 | }, 492 | { 493 | "fieldType": "time", 494 | "fieldID": "endTime", 495 | "title": "End Time", 496 | "questionText": "Around when will the canoe trip be finished?" 497 | }, 498 | { 499 | "fieldType": "description", 500 | "fieldID": "description", 501 | "title": "Description", 502 | "questionText": "What additional information do you want everyone to know about this canoe trip?" 503 | } 504 | ] 505 | }, 506 | "special_event": { 507 | "metaData": { 508 | "eventID": "SPECIAL_EVENT", 509 | "subtitle": "Plan an event that doesn't fit any of our templates above.", 510 | "image": { 511 | "uri": "https://res.cloudinary.com/wow-your-client/image/upload/c_scale,w_600/v1599241295/ScoutTrek/luke-porter-mGFJIUD9yiM-unsplash.jpg" 512 | } 513 | }, 514 | "options": [ 515 | { 516 | "condition": "uniqueMeetLocation", 517 | "hidden": "The event location.", 518 | "shown": "A different meet point.", 519 | "hiddenFields": [ 520 | "meetLocation", 521 | "meetTime", 522 | "leaveTime" 523 | ] 524 | } 525 | ], 526 | "form": [ 527 | { 528 | "fieldType": "shortText", 529 | "fieldID": "title", 530 | "title": "Special Event Title", 531 | "questionText": "What do you want to call this special event?" 532 | }, 533 | { 534 | "fieldType": "location", 535 | "fieldID": "location", 536 | "title": "Location", 537 | "questionText": "Where will this event be?" 538 | }, 539 | { 540 | "fieldType": "setting", 541 | "fieldID": "uniqueMeetLocation", 542 | "questionText": "Where do you want everyone to meet?", 543 | "payload": { 544 | "options": [ 545 | "The event location.", 546 | "A different meet point." 547 | ] 548 | } 549 | }, 550 | { 551 | "fieldType": "date", 552 | "fieldID": "date", 553 | "title": "Date", 554 | "questionText": "When is this event?" 555 | }, 556 | { 557 | "fieldType": "time", 558 | "fieldID": "startTime", 559 | "title": "Time", 560 | "questionText": "What time does the event start?" 561 | }, 562 | { 563 | "fieldType": "location", 564 | "fieldID": "meetLocation", 565 | "title": "Meet Location", 566 | "questionText": "Where should everyone meet?" 567 | }, 568 | { 569 | "fieldType": "time", 570 | "fieldID": "meetTime", 571 | "title": "Meet Time", 572 | "questionText": "What time should everybody get to your meet place?" 573 | }, 574 | { 575 | "fieldType": "time", 576 | "fieldID": "leaveTime", 577 | "title": "Leave Time", 578 | "questionText": "What time do you plan to leave your meet place?" 579 | }, 580 | { 581 | "fieldType": "time", 582 | "fieldID": "endTime", 583 | "title": "End Time", 584 | "questionText": "Around when will the event be finished?" 585 | }, 586 | { 587 | "fieldType": "description", 588 | "fieldID": "description", 589 | "title": "Description", 590 | "questionText": "What additional information do you want everyone to know about this event?" 591 | } 592 | ] 593 | } 594 | } 595 | --------------------------------------------------------------------------------