diff options
-rw-r--r-- | app.ts | 2 | ||||
-rw-r--r-- | config/default.json | 2 | ||||
-rw-r--r-- | hooks/convertPoll.ts | 43 | ||||
-rw-r--r-- | hooks/expandAuthor.ts | 32 | ||||
-rw-r--r-- | hooks/requireAuth.ts | 7 | ||||
-rw-r--r-- | hooks/tryAuthenticate.ts | 8 | ||||
-rw-r--r-- | models/polls/poll.model.ts | 15 | ||||
-rw-r--r-- | models/polls/poll.schema.ts | 22 | ||||
-rw-r--r-- | models/users/user.schema.ts | 17 | ||||
-rw-r--r-- | models/votes/vote.model.ts | 7 | ||||
-rw-r--r-- | models/votes/vote.schema.ts | 25 | ||||
-rw-r--r-- | package-lock.json | 6 | ||||
-rw-r--r-- | package.json | 3 | ||||
-rw-r--r-- | populateDb.ts | 41 | ||||
-rw-r--r-- | services/auth/auth.service.ts | 2 | ||||
-rw-r--r-- | services/index.ts | 10 | ||||
-rw-r--r-- | services/polls/polls.hooks.ts | 8 | ||||
-rw-r--r-- | services/profiles/profiles.hooks.ts | 6 | ||||
-rw-r--r-- | services/users/users.hooks.ts | 10 | ||||
-rw-r--r-- | services/votes/votes.hooks.ts | 15 | ||||
-rw-r--r-- | services/votes/votes.service.ts | 13 |
21 files changed, 210 insertions, 84 deletions
@@ -1,7 +1,7 @@ import feathers from '@feathersjs/feathers'; import express from '@feathersjs/express'; import socketio from '@feathersjs/socketio'; -import configuration from '@feathersjs/configuration' +import configuration from '@feathersjs/configuration'; import '@feathersjs/transport-commons'; import cors from 'cors'; diff --git a/config/default.json b/config/default.json index e112f3e..3d8ffd2 100644 --- a/config/default.json +++ b/config/default.json @@ -8,7 +8,7 @@ "local" ], "local": { - "usernameField": "name", + "usernameField": "username", "passwordField": "password" } } diff --git a/hooks/convertPoll.ts b/hooks/convertPoll.ts new file mode 100644 index 0000000..1c32851 --- /dev/null +++ b/hooks/convertPoll.ts @@ -0,0 +1,43 @@ +import { HookContext } from '@feathersjs/feathers'; +import { Types } from 'mongoose'; +import bluebird from 'bluebird'; +import _ from 'lodash'; +import { Poll, User, Vote } from 'which-types'; + +import { PollSchema } from '../models/polls/poll.schema'; +import VoteModel from '../models/votes/vote.model'; + + +export default async (context: HookContext): Promise<HookContext> => { + const { app, result, params: { user } } = context; + + const convert = async (poll: PollSchema): Promise<Poll | null> => { + const author = await app.service('users').get(poll.authorId); + + const contents = await VoteModel.aggregate([ + { $match: { pollId: Types.ObjectId(poll._id) } }, + { $group: { _id: '$which', total: { $sum: 1 } } } + ]).then(groups => groups.reduce( + (acc, group) => _.set(acc, group._id + '.votes', group.total), + { left: { votes: 0 }, right: { votes: 0 } } + )); + + const userChoice = await VoteModel.findOne( + { pollId: poll._id, userId: user?._id } + ).then(vote => vote?.which); + + return _.merge( + _.omit(poll, ['authorId']), + { author, contents, userChoice } + ); + }; + + if (Array.isArray(result)) { + const polls = await bluebird.map(result, (poll: PollSchema) => convert(poll)); + context.result = _.compact(polls); + } else { + context.result = await convert(result); + } + return context; +}; + diff --git a/hooks/expandAuthor.ts b/hooks/expandAuthor.ts deleted file mode 100644 index 3b3e3df..0000000 --- a/hooks/expandAuthor.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { HookContext } from '@feathersjs/feathers'; -import bluebird from 'bluebird'; -import _ from 'lodash'; - -import { Poll, PollSchema } from '../models/polls/poll.schema'; -import { User } from '../models/users/user.schema'; -import UserModel from '../models/users/user.model'; - -const expandAuthor = async (poll: PollSchema): Promise<Poll | null> => { - return UserModel.findById(poll.authorId) - .lean<User>() - .exec() - .then((author: User | null): Poll | null => { - return author && _.merge(_.omit(poll, 'authorId'), { author }); - }) - .catch(err => { - console.error(err); - return err; - }); -}; - -export const expandAuthorHook = async (context: HookContext): Promise<HookContext> => { - context.result = await expandAuthor(context.result); - return context; -}; - -export const expandAuthorManyHook = async (context: HookContext): Promise<HookContext> => { - const polls = await bluebird.map(context.result, (poll: PollSchema) => expandAuthor(poll)); - context.result = _.compact(polls); - return context; -}; - diff --git a/hooks/requireAuth.ts b/hooks/requireAuth.ts new file mode 100644 index 0000000..a7b0e96 --- /dev/null +++ b/hooks/requireAuth.ts @@ -0,0 +1,7 @@ +import { HookContext } from '@feathersjs/feathers'; + +export default async (context: HookContext): Promise<HookContext> => { + if (!context.params.user) throw new Error('This endpoint requires auth!'); + return context; +}; + diff --git a/hooks/tryAuthenticate.ts b/hooks/tryAuthenticate.ts new file mode 100644 index 0000000..e179417 --- /dev/null +++ b/hooks/tryAuthenticate.ts @@ -0,0 +1,8 @@ +import { HookContext } from '@feathersjs/feathers'; +import { authenticate } from '@feathersjs/authentication'; + + +export default async (context: HookContext): Promise<HookContext> => { + return authenticate('jwt')(context).catch(() => context); +}; + diff --git a/models/polls/poll.model.ts b/models/polls/poll.model.ts index 7f6be9a..6749e5c 100644 --- a/models/polls/poll.model.ts +++ b/models/polls/poll.model.ts @@ -1,5 +1,20 @@ import { Model, model } from 'mongoose'; import { PollSchema, pollSchema } from './poll.schema'; +import { Types } from 'mongoose'; +import { Which } from 'which-types'; + +pollSchema.methods.vote = function(userId: string, which: Which): PollSchema { + const participants: Types.ObjectId[] = ['left', 'right'].reduce((acc, option) => { + const { votes } = this.contents[option]; + return acc.concat(votes); + }, []); + + if (!participants.some(user => user.equals(userId))) { + this.contents[which].votes.push(userId); + } + + return this.save(); +} export default model<PollSchema, Model<PollSchema>>('Poll', pollSchema); diff --git a/models/polls/poll.schema.ts b/models/polls/poll.schema.ts index bc6d497..4d1d762 100644 --- a/models/polls/poll.schema.ts +++ b/models/polls/poll.schema.ts @@ -1,27 +1,21 @@ import { Document, Schema, Types } from 'mongoose'; -import { User } from '../users/user.schema'; -export interface ImageData { +export interface ImageDataSchema { url: string; - votes: number; } -export interface Poll { - author: User; +export interface PollSchema extends Document { contents: { - left: ImageData; - right: ImageData; + left: ImageDataSchema; + right: ImageDataSchema; }; -} - -export interface PollSchema extends Document, Omit<Poll, 'author'> { + createdAt: Date; authorId: string; + vote: (userId: string, which: 'left' | 'right') => PollSchema; } - -const imageDataSchema = { +export const imageDataSchema = { url: String, - votes: Number }; export const pollSchema = new Schema({ @@ -33,5 +27,5 @@ export const pollSchema = new Schema({ type: Types.ObjectId, ref: 'User' } -}); +}, { timestamps: true }); diff --git a/models/users/user.schema.ts b/models/users/user.schema.ts index fd7d1e1..ff6cbe9 100644 --- a/models/users/user.schema.ts +++ b/models/users/user.schema.ts @@ -1,24 +1,21 @@ import { Document, Schema } from 'mongoose'; +import { User } from 'which-types'; -export interface User { - name: string; - avatarUrl?: string; - age?: number; -} - -export interface UserSchema extends Document, User { +export interface UserSchema extends Document, Omit<User, '_id'> { password: string; } export const userSchema = new Schema({ - name: String, + username: String, password: String, + email: String, avatarUrl: { type: String, required: false }, age: { - type: Number + type: Number, + required: false } -}); +}, { timestamps: true }); diff --git a/models/votes/vote.model.ts b/models/votes/vote.model.ts new file mode 100644 index 0000000..df2307e --- /dev/null +++ b/models/votes/vote.model.ts @@ -0,0 +1,7 @@ +import { Model, model } from 'mongoose'; +import { VoteSchema, voteSchema } from './vote.schema'; + +voteSchema.index({ pollId: 1, userId: 1 }, { unique: true }); // Unique together + +export default model<VoteSchema, Model<VoteSchema>>('Vote', voteSchema); + diff --git a/models/votes/vote.schema.ts b/models/votes/vote.schema.ts new file mode 100644 index 0000000..63ba212 --- /dev/null +++ b/models/votes/vote.schema.ts @@ -0,0 +1,25 @@ +import { Document, Schema, Types } from 'mongoose'; +import { Vote } from 'which-types'; + +export interface VoteSchema extends Document, Omit<Vote, '_id'> { + password: string; +} + +export const voteSchema = new Schema({ + userId: { + type: Types.ObjectId, + ref: 'user', + required: true + }, + pollId: { + type: Types.ObjectId, + ref: 'poll', + required: true + }, + which: { + type: String, + enum: ['left', 'right'], + required: true + } +}, { timestamps: true }); + diff --git a/package-lock.json b/package-lock.json index 771797b..1402c27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3050,6 +3050,12 @@ "isexe": "^2.0.0" } }, + "which-types": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/which-types/-/which-types-1.4.0.tgz", + "integrity": "sha512-dq5f7AmZeataiQZxko+Dw1xRvWdzSXTwXrjKxtyL5zqLgS29J1PXjTgcAWMfbkZhbd8+iJLkfxKvYZ42itmxeA==", + "dev": true + }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", diff --git a/package.json b/package.json index 7116faa..ecdd31d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "eslint": "^7.2.0", "eslint-config-airbnb-typescript": "^8.0.2", "eslint-plugin-import": "^2.21.2", - "typescript": "^3.9.5" + "typescript": "^3.9.5", + "which-types": "^1.4.0" } } diff --git a/populateDb.ts b/populateDb.ts index a21b669..757280a 100644 --- a/populateDb.ts +++ b/populateDb.ts @@ -1,9 +1,9 @@ import mongoose from 'mongoose'; import bluebird from 'bluebird'; import _ from 'lodash'; +import { User, Poll, Vote } from 'which-types'; + import app from './app'; -import { UserSchema } from './models/users/user.schema'; -import { PollSchema, ImageData } from './models/polls/poll.schema'; mongoose.connect('mongodb://localhost:27017/which', { useNewUrlParser: true }); @@ -28,12 +28,17 @@ const names: string[] = [ 'William' ]; -const generateImageData = (): ImageData => ({ - url: _.sample(imageUrls) || '', - votes: Math.floor(Math.random() * 101) -}); +const choices = [ + 'left', + 'right' +]; + + +const createPoll = (authorId: string): Promise<Poll> => { + const generateImageData = () => ({ + url: _.sample(imageUrls) || '', + }); -const createPoll = (authorId: string): Promise<PollSchema> => { return app.service('polls').create({ contents: { left: generateImageData(), @@ -43,21 +48,33 @@ const createPoll = (authorId: string): Promise<PollSchema> => { }); }; -const createUser = (name: string): Promise<UserSchema> => { +const createUser = (username: string): Promise<User> => { return app.service('users').create({ avatarUrl: _.sample(imageUrls) || '', password: 'supersecret', - name + username }); }; +const createVote = (userId: string, pollId: string): Promise<Vote> => { + return app.service('votes').create({ + pollId, + which: _.sample(choices) + }, { user: { _id: userId } }); +} + const populate = async () => { const users = await bluebird.map(names, name => createUser(name)); - await bluebird.mapSeries(new Array(POLLS_AMOUNT), async () => { - const sampleUser = _.sample(users); - return createPoll(sampleUser?._id); + const polls = await bluebird.mapSeries(new Array(POLLS_AMOUNT), async () => { + const user = _.sample(users); + return createPoll(user?._id || ''); + }); + + const votes = await bluebird.map(users, user => { + const pollsToVote = _.sampleSize(polls, _.random(0, POLLS_AMOUNT)); + return bluebird.map(pollsToVote, poll => createVote(user?._id || '', poll?._id || '')); }); }; diff --git a/services/auth/auth.service.ts b/services/auth/auth.service.ts index 42846b0..826357c 100644 --- a/services/auth/auth.service.ts +++ b/services/auth/auth.service.ts @@ -1,6 +1,6 @@ import { AuthenticationService, - JWTStrategy + JWTStrategy } from '@feathersjs/authentication'; import { LocalStrategy } from '@feathersjs/authentication-local'; import { Application } from '@feathersjs/express'; diff --git a/services/index.ts b/services/index.ts index f000837..fe5ffdc 100644 --- a/services/index.ts +++ b/services/index.ts @@ -2,12 +2,22 @@ import { Application } from '@feathersjs/express'; import Users from './users/users.service'; import Polls from './polls/polls.service'; import Profiles from './profiles/profiles.service'; +import Votes from './votes/votes.service'; import Auth from './auth/auth.service'; +import tryAuthenticate from '../hooks/tryAuthenticate'; + export default (app: Application): void => { app.configure(Auth); app.configure(Users); app.configure(Polls); app.configure(Profiles); + app.configure(Votes); + + app.hooks({ + before: { + all: tryAuthenticate + } + }) }; diff --git a/services/polls/polls.hooks.ts b/services/polls/polls.hooks.ts index 0637320..eba3e63 100644 --- a/services/polls/polls.hooks.ts +++ b/services/polls/polls.hooks.ts @@ -1,12 +1,8 @@ -import { - expandAuthorHook, - expandAuthorManyHook -} from '../../hooks/expandAuthor'; +import convertPoll from '../../hooks/convertPoll'; export default { after: { - get: [expandAuthorHook], - find: [expandAuthorManyHook] + all: [convertPoll], } }; diff --git a/services/profiles/profiles.hooks.ts b/services/profiles/profiles.hooks.ts index bb05d94..13d6f63 100644 --- a/services/profiles/profiles.hooks.ts +++ b/services/profiles/profiles.hooks.ts @@ -1,10 +1,8 @@ -import { - expandAuthorManyHook -} from '../../hooks/expandAuthor'; +import convertPoll from '../../hooks/convertPoll'; export default { after: { - get: [expandAuthorManyHook] + all: [convertPoll] } }; diff --git a/services/users/users.hooks.ts b/services/users/users.hooks.ts index fc17ed7..8eecca3 100644 --- a/services/users/users.hooks.ts +++ b/services/users/users.hooks.ts @@ -1,11 +1,17 @@ import { hooks } from '@feathersjs/authentication-local'; +import { HookContext } from '@feathersjs/feathers'; const hashPassword = hooks.hashPassword('password'); -const protectPassword = hooks.protect('password'); + +const localDispatch = async (context: HookContext): Promise<HookContext> => { + context.result = context.dispatch; + return context; +} export default { after: { - all: [protectPassword] + all: [hooks.protect('password')], + get: [localDispatch] // Protect password from local get's }, before: { create: [hashPassword], diff --git a/services/votes/votes.hooks.ts b/services/votes/votes.hooks.ts new file mode 100644 index 0000000..1cf7261 --- /dev/null +++ b/services/votes/votes.hooks.ts @@ -0,0 +1,15 @@ +import { HookContext } from '@feathersjs/feathers'; +import requireAuth from '../../hooks/requireAuth'; + +const addUserId = async (context: HookContext): Promise<HookContext> => { + const { params: { user} } = context; + context.data.userId = user._id; + return context; +}; + +export default { + before: { + create: [requireAuth, addUserId] + } +}; + diff --git a/services/votes/votes.service.ts b/services/votes/votes.service.ts new file mode 100644 index 0000000..cb40c1a --- /dev/null +++ b/services/votes/votes.service.ts @@ -0,0 +1,13 @@ +import { Application } from '@feathersjs/express'; +import service from 'feathers-mongoose'; +import Model from '../../models/votes/vote.model'; + +import hooks from './votes.hooks'; + +const VoteService = service({ Model }); + +export default (app: Application): void => { + app.use('/votes', VoteService); + app.service('votes').hooks(hooks); +}; + |