From 2fe9d5ca9ce1fb376f03534f8685d65efb2f68a6 Mon Sep 17 00:00:00 2001 From: Saad Jutt Date: Sun, 7 Nov 2021 05:14:37 +0500 Subject: [PATCH] feat: Groups are added + docs --- public/swagger.yaml | 235 ++++++++++++++++++++++++++++++++++++++- src/controllers/auth.ts | 6 +- src/controllers/group.ts | 215 +++++++++++++++++++++++++++++++++++ src/controllers/user.ts | 21 ++-- src/model/Group.ts | 87 +++++++++++++++ src/model/User.ts | 2 + src/routes/api/drive.ts | 2 + src/routes/api/group.ts | 97 ++++++++++++++++ src/routes/api/index.ts | 2 + src/utils/validation.ts | 7 ++ tsoa.json | 4 + 11 files changed, 664 insertions(+), 14 deletions(-) create mode 100644 src/controllers/group.ts create mode 100644 src/model/Group.ts create mode 100644 src/routes/api/group.ts diff --git a/public/swagger.yaml b/public/swagger.yaml index 09135db..cad804e 100644 --- a/public/swagger.yaml +++ b/public/swagger.yaml @@ -147,6 +147,63 @@ components: - password type: object additionalProperties: false + GroupResponse: + properties: + groupId: + type: number + format: double + name: + type: string + description: + type: string + required: + - groupId + - name + - description + type: object + additionalProperties: false + GroupDetailsResponse: + properties: + groupId: + type: number + format: double + name: + type: string + description: + type: string + isActive: + type: boolean + users: + items: + $ref: '#/components/schemas/UserResponse' + type: array + required: + - groupId + - name + - description + - isActive + - users + type: object + additionalProperties: false + GroupPayload: + properties: + name: + type: string + description: 'Name of the group' + example: DCGroup + description: + type: string + description: 'Description of the group' + example: 'This group represents Data Controller Users' + isActive: + type: boolean + description: 'Group should be active or not, defaults to true' + example: 'true' + required: + - name + - description + type: object + additionalProperties: false ClientPayload: properties: clientId: @@ -177,7 +234,7 @@ components: username: type: string description: 'Username for user' - example: johnSnow01 + example: secretuser password: type: string description: 'Password for user' @@ -185,7 +242,7 @@ components: clientId: type: string description: 'Client ID' - example: someFormattedClientID1234 + example: clientID1 required: - username - password @@ -212,7 +269,7 @@ components: clientId: type: string description: 'Client ID' - example: someFormattedClientID1234 + example: clientID1 code: type: string description: 'Authorization code' @@ -428,6 +485,175 @@ paths: password: type: string type: object + /SASjsApi/group: + get: + operationId: GetAllGroups + responses: + '200': + description: Ok + content: + application/json: + schema: + items: + $ref: '#/components/schemas/GroupResponse' + type: array + examples: + 'Example 1': + value: [{groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users'}] + description: 'Get list of all groups (groupName and groupDescription). All users can request this.' + tags: + - Group + security: + - + bearerAuth: [] + parameters: [] + post: + operationId: CreateGroup + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/GroupDetailsResponse' + examples: + 'Example 1': + value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []} + description: 'Create a new group. Admin only.' + tags: + - Group + security: + - + bearerAuth: [] + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GroupPayload' + '/SASjsApi/group/{groupId}': + get: + operationId: GetGroup + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/GroupDetailsResponse' + description: 'Get list of members of a group (userName). All users can request this.' + tags: + - Group + security: + - + bearerAuth: [] + parameters: + - + description: 'The group''s identifier' + in: path + name: groupId + required: true + schema: + format: double + type: number + example: 1234 + delete: + operationId: DeleteGroup + responses: + '204': + description: 'No content' + description: 'Delete a group. Admin task only.' + tags: + - Group + security: + - + bearerAuth: [] + parameters: + - + description: 'The group''s identifier' + in: path + name: groupId + required: true + schema: + format: double + type: number + example: 1234 + '/SASjsApi/group/{groupId}/{userId}': + post: + operationId: AddUserToGroup + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/GroupDetailsResponse' + examples: + 'Example 1': + value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []} + description: 'Add a user to a group. Admin task only.' + tags: + - Group + security: + - + bearerAuth: [] + parameters: + - + description: 'The group''s identifier' + in: path + name: groupId + required: true + schema: + format: double + type: number + example: '1234' + - + description: 'The user''s identifier' + in: path + name: userId + required: true + schema: + format: double + type: number + example: '6789' + delete: + operationId: RemoveUserFromGroup + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/GroupDetailsResponse' + examples: + 'Example 1': + value: {groupId: 123, name: DCGroup, description: 'This group represents Data Controller Users', isActive: true, users: []} + description: 'Remove a user to a group. Admin task only.' + tags: + - Group + security: + - + bearerAuth: [] + parameters: + - + description: 'The group''s identifier' + in: path + name: groupId + required: true + schema: + format: double + type: number + example: '1234' + - + description: 'The user''s identifier' + in: path + name: userId + required: true + schema: + format: double + type: number + example: '6789' /SASjsApi/client: post: operationId: CreateClient @@ -551,3 +777,6 @@ tags: - name: Drive description: 'Operations about drive' + - + name: Group + description: 'Operations about group' diff --git a/src/controllers/auth.ts b/src/controllers/auth.ts index 002d65a..2c6fbcb 100644 --- a/src/controllers/auth.ts +++ b/src/controllers/auth.ts @@ -137,7 +137,7 @@ const logout = async (userInfo: InfoJWT) => { interface AuthorizePayload { /** * Username for user - * @example "johnSnow01" + * @example "secretuser" */ username: string /** @@ -147,7 +147,7 @@ interface AuthorizePayload { password: string /** * Client ID - * @example "someFormattedClientID1234" + * @example "clientID1" */ clientId: string } @@ -163,7 +163,7 @@ interface AuthorizeResponse { interface TokenPayload { /** * Client ID - * @example "someFormattedClientID1234" + * @example "clientID1" */ clientId: string /** diff --git a/src/controllers/group.ts b/src/controllers/group.ts new file mode 100644 index 0000000..7cd7aa2 --- /dev/null +++ b/src/controllers/group.ts @@ -0,0 +1,215 @@ +import { + Security, + Route, + Tags, + Path, + Example, + Get, + Post, + Delete, + Body +} from 'tsoa' + +import Group, { GroupPayload } from '../model/Group' +import User from '../model/User' +import { UserResponse } from './user' + +interface GroupResponse { + groupId: number + name: string + description: string +} + +interface GroupDetailsResponse { + groupId: number + name: string + description: string + isActive: boolean + users: UserResponse[] +} + +@Security('bearerAuth') +@Route('SASjsApi/group') +@Tags('Group') +export default class GroupController { + /** + * Get list of all groups (groupName and groupDescription). All users can request this. + * + */ + @Example([ + { + groupId: 123, + name: 'DCGroup', + description: 'This group represents Data Controller Users' + } + ]) + @Get('/') + public async getAllGroups(): Promise { + return getAllGroups() + } + + /** + * Create a new group. Admin only. + * + */ + @Example({ + groupId: 123, + name: 'DCGroup', + description: 'This group represents Data Controller Users', + isActive: true, + users: [] + }) + @Post('/') + public async createGroup( + @Body() body: GroupPayload + ): Promise { + return createGroup(body) + } + + /** + * Get list of members of a group (userName). All users can request this. + * @param groupId The group's identifier + * @example groupId 1234 + */ + @Get('{groupId}') + public async getGroup( + @Path() groupId: number + ): Promise { + return getGroup(groupId) + } + + /** + * Add a user to a group. Admin task only. + * @param groupId The group's identifier + * @example groupId "1234" + * @param userId The user's identifier + * @example userId "6789" + */ + @Example({ + groupId: 123, + name: 'DCGroup', + description: 'This group represents Data Controller Users', + isActive: true, + users: [] + }) + @Post('{groupId}/{userId}') + public async addUserToGroup( + @Path() groupId: number, + @Path() userId: number + ): Promise { + return addUserToGroup(groupId, userId) + } + + /** + * Remove a user to a group. Admin task only. + * @param groupId The group's identifier + * @example groupId "1234" + * @param userId The user's identifier + * @example userId "6789" + */ + @Example({ + groupId: 123, + name: 'DCGroup', + description: 'This group represents Data Controller Users', + isActive: true, + users: [] + }) + @Delete('{groupId}/{userId}') + public async removeUserFromGroup( + @Path() groupId: number, + @Path() userId: number + ): Promise { + return removeUserFromGroup(groupId, userId) + } + + /** + * Delete a group. Admin task only. + * @param groupId The group's identifier + * @example groupId 1234 + */ + @Delete('{groupId}') + public async deleteGroup(@Path() groupId: number) { + const { deletedCount } = await Group.deleteOne({ groupId }) + if (deletedCount) return + throw new Error('No Group deleted!') + } +} + +const getAllGroups = async (): Promise => + await Group.find({}) + .select({ _id: 0, groupId: 1, name: 1, description: 1 }) + .exec() + +const createGroup = async ({ + name, + description, + isActive +}: GroupPayload): Promise => { + const group = new Group({ + name, + description, + isActive + }) + + const savedGroup = await group.save() + + return { + groupId: savedGroup.groupId, + name: savedGroup.name, + description: savedGroup.description, + isActive: savedGroup.isActive, + users: [] + } +} + +const getGroup = async (groupId: number): Promise => { + const group = (await Group.findOne({ groupId }).populate( + 'users', + 'id username displayName -_id' + )) as unknown as GroupDetailsResponse + if (!group) throw new Error('Group is not found.') + + return { + groupId: group.groupId, + name: group.name, + description: group.description, + isActive: group.isActive, + users: group.users + } +} + +const addUserToGroup = async ( + groupId: number, + userId: number +): Promise => { + const group = await Group.findOne({ groupId }) + if (!group) throw new Error('Group not found') + + const user = await User.findOne({ id: userId }) + if (!user) throw new Error('User not found') + + const updatedGroup = (await group.addUser( + user._id + )) as unknown as GroupDetailsResponse + if (!updatedGroup) throw new Error('Unable to update group') + + return updatedGroup +} + +const removeUserFromGroup = async ( + groupId: number, + userId: number +): Promise => { + const group = await Group.findOne({ groupId }) + if (!group) throw new Error('Group not found') + + const user = await User.findOne({ id: userId }) + if (!user) throw new Error('User not found') + + const updatedGroup = (await group.removeUser( + user._id + )) as unknown as GroupDetailsResponse + if (!updatedGroup) throw new Error('Unable to update group') + + return updatedGroup +} diff --git a/src/controllers/user.ts b/src/controllers/user.ts index e6afa5c..5e4573c 100644 --- a/src/controllers/user.ts +++ b/src/controllers/user.ts @@ -16,7 +16,7 @@ import bcrypt from 'bcryptjs' import User, { UserPayload } from '../model/User' -interface UserResponse { +export interface UserResponse { id: number username: string displayName: string @@ -123,7 +123,7 @@ const getAllUsers = async (): Promise => .select({ _id: 0, id: 1, username: 1, displayName: 1 }) .exec() -const createUser = async (data: any): Promise => { +const createUser = async (data: UserPayload): Promise => { const { displayName, username, password, isAdmin, isActive } = data // Checking if user is already in the database @@ -154,7 +154,7 @@ const createUser = async (data: any): Promise => { } } -const getUser = async (id: number) => { +const getUser = async (id: number): Promise => { const user = await User.findOne({ id }) .select({ _id: 0, @@ -170,7 +170,10 @@ const getUser = async (id: number) => { return user } -const updateUser = async (id: number, data: any) => { +const updateUser = async ( + id: number, + data: UserPayload +): Promise => { const { displayName, username, password, isAdmin, isActive } = data const params: any = { displayName, username, isAdmin, isActive } @@ -196,14 +199,16 @@ const updateUser = async (id: number, data: any) => { return updatedUser } -const deleteUser = async (id: number, isAdmin: boolean, data: any) => { - const { password } = data - +const deleteUser = async ( + id: number, + isAdmin: boolean, + { password }: { password?: string } +) => { const user = await User.findOne({ id }) if (!user) throw new Error('User is not found.') if (!isAdmin) { - const validPass = await bcrypt.compare(password, user.password) + const validPass = await bcrypt.compare(password!, user.password) if (!validPass) throw new Error('Invalid password.') } diff --git a/src/model/Group.ts b/src/model/Group.ts new file mode 100644 index 0000000..c081894 --- /dev/null +++ b/src/model/Group.ts @@ -0,0 +1,87 @@ +import mongoose, { Schema, model, Document, Model } from 'mongoose' +const AutoIncrement = require('mongoose-sequence')(mongoose) + +export interface GroupPayload { + /** + * Name of the group + * @example "DCGroup" + */ + name: string + /** + * Description of the group + * @example "This group represents Data Controller Users" + */ + description: string + /** + * Group should be active or not, defaults to true + * @example "true" + */ + isActive?: boolean +} + +interface IGroupDocument extends GroupPayload, Document { + groupId: number + isActive: boolean + users: Schema.Types.ObjectId[] +} + +interface IGroup extends IGroupDocument { + addUser(userObjectId: Schema.Types.ObjectId): Promise + removeUser(userObjectId: Schema.Types.ObjectId): Promise +} +interface IGroupModel extends Model {} + +const groupSchema = new Schema({ + name: { + type: String, + required: true + }, + description: { + type: String, + default: 'Group description.' + }, + isActive: { + type: Boolean, + default: true + }, + users: [{ type: Schema.Types.ObjectId, ref: 'User' }] +}) +groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' }) + +// Hooks +groupSchema.post('save', function (group: IGroup, next: Function) { + group.populate('users', 'id username displayName -_id').then(function () { + next() + }) +}) + +// Instance Methods +groupSchema.method( + 'addUser', + async function (userObjectId: Schema.Types.ObjectId) { + const userIdIndex = this.users.indexOf(userObjectId) + if (userIdIndex === -1) { + this.users.push(userObjectId) + } + this.markModified('users') + return this.save() + } +) +groupSchema.method( + 'removeUser', + async function (userObjectId: Schema.Types.ObjectId) { + const userIdIndex = this.users.indexOf(userObjectId) + if (userIdIndex > -1) { + this.users.splice(userIdIndex, 1) + } + this.markModified('users') + return this.save() + } +) + +export const Group: IGroupModel = model( + 'Group', + groupSchema +) + +export default Group diff --git a/src/model/User.ts b/src/model/User.ts index 5db3121..589f4f5 100644 --- a/src/model/User.ts +++ b/src/model/User.ts @@ -32,6 +32,7 @@ interface User extends UserPayload { id: number isAdmin: boolean isActive: boolean + groups: Schema.Types.ObjectId[] tokens: [{ [key: string]: string }] } @@ -57,6 +58,7 @@ const UserSchema = new Schema({ type: Boolean, default: true }, + groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }], tokens: [ { clientId: { diff --git a/src/routes/api/drive.ts b/src/routes/api/drive.ts index f5e8c0a..5a34a56 100644 --- a/src/routes/api/drive.ts +++ b/src/routes/api/drive.ts @@ -10,7 +10,9 @@ driveRouter.post('/deploy', async (req, res) => { res.send(response) } catch (err: any) { const statusCode = err.code + delete err.code + res.status(statusCode).send(err) } }) diff --git a/src/routes/api/group.ts b/src/routes/api/group.ts new file mode 100644 index 0000000..a4d14e5 --- /dev/null +++ b/src/routes/api/group.ts @@ -0,0 +1,97 @@ +import express from 'express' +import GroupController from '../../controllers/group' +import { authenticateAccessToken, verifyAdmin } from '../../middlewares' +import { registerGroupValidation } from '../../utils' +import userRouter from './user' + +const groupRouter = express.Router() + +groupRouter.post( + '/', + authenticateAccessToken, + verifyAdmin, + async (req, res) => { + const { error, value: body } = registerGroupValidation(req.body) + if (error) return res.status(400).send(error.details[0].message) + + const controller = new GroupController() + try { + const response = await controller.createGroup(body) + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) + } + } +) + +groupRouter.get('/', authenticateAccessToken, async (req, res) => { + const controller = new GroupController() + try { + const response = await controller.getAllGroups() + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) + } +}) + +groupRouter.get('/:groupId', authenticateAccessToken, async (req: any, res) => { + const { groupId } = req.params + + const controller = new GroupController() + try { + const response = await controller.getGroup(groupId) + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) + } +}) + +groupRouter.post( + '/:groupId/:userId', + authenticateAccessToken, + async (req: any, res) => { + const { groupId, userId } = req.params + + const controller = new GroupController() + try { + const response = await controller.addUserToGroup(groupId, userId) + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) + } + } +) + +groupRouter.delete( + '/:groupId/:userId', + authenticateAccessToken, + async (req: any, res) => { + const { groupId, userId } = req.params + + const controller = new GroupController() + try { + const response = await controller.removeUserFromGroup(groupId, userId) + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) + } + } +) + +groupRouter.delete( + '/:groupId', + authenticateAccessToken, + async (req: any, res) => { + const { groupId } = req.params + + const controller = new GroupController() + try { + await controller.deleteGroup(groupId) + res.status(200).send('Group Deleted!') + } catch (err: any) { + res.status(403).send(err.toString()) + } + } +) + +export default groupRouter diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index 8b9c3ac..d10a781 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -7,6 +7,7 @@ import { authenticateAccessToken, verifyAdmin } from '../../middlewares' import driveRouter from './drive' import stpRouter from './stp' import userRouter from './user' +import groupRouter from './group' import clientRouter from './client' import authRouter, { connectDB } from './auth' @@ -18,6 +19,7 @@ const router = express.Router() router.use('/drive', authenticateAccessToken, driveRouter) router.use('/stp', authenticateAccessToken, stpRouter) router.use('/user', userRouter) +router.use('/group', groupRouter) router.use('/client', authenticateAccessToken, verifyAdmin, clientRouter) router.use('/auth', authRouter) router.use( diff --git a/src/utils/validation.ts b/src/utils/validation.ts index a8ec8d3..de78fad 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -16,6 +16,13 @@ export const tokenValidation = (data: any): Joi.ValidationResult => code: Joi.string().required() }).validate(data) +export const registerGroupValidation = (data: any): Joi.ValidationResult => + Joi.object({ + name: Joi.string().min(6).required(), + description: Joi.string(), + isActive: Joi.boolean() + }).validate(data) + export const registerUserValidation = (data: any): Joi.ValidationResult => Joi.object({ displayName: Joi.string().min(6).required(), diff --git a/tsoa.json b/tsoa.json index 83fb1d9..fffd20d 100644 --- a/tsoa.json +++ b/tsoa.json @@ -26,6 +26,10 @@ { "name": "Drive", "description": "Operations about drive" + }, + { + "name": "Group", + "description": "Operations about group" } ], "yaml": true,