├── 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 |
--------------------------------------------------------------------------------