Skip to content

Commit

Permalink
Refactor Poll Service (#182)
Browse files Browse the repository at this point in the history
* refactor(backend): Move poll service and controller

* refactor(backend): Rename PollService to PollActionsService

* refactor(backend): Add participant module

* refactor(backend): Move participant endpoints to ParticipantController

* refactor(backend): Add poll-event module

* fix(backend): Poll security

- isAdmin now checks createdBy for registered users.
- Non-owners can no longer edit poll events

* refactor(backend): Use @NotFound

* style(backend): PollController indent

* style(backend): poll-actions.service.ts imports

* chore: Fix lint problems

* test(backend): Fix broken tests

* feat(backend): Show own polls via createdBy

---------

Co-authored-by: Clashsoft <Clashsoft@users.noreply.github.com>
  • Loading branch information
Clashsoft and Clashsoft authored Jul 28, 2024
1 parent 133bca1 commit ab51129
Show file tree
Hide file tree
Showing 14 changed files with 367 additions and 231 deletions.
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {OptionalAuthGuard} from './auth/optional-auth.guard';
import {environment} from './environment';
import {ImprintModule} from './imprint/imprint.module';
import {MailModule} from './mail/mail.module';
import {ParticipantModule} from './participant/participant.module';
import {PollModule} from './poll/poll.module';
import {PushModule} from './push/push.module';
import {StatisticsModule} from './statistics/statistics.module';
Expand All @@ -16,6 +17,7 @@ import {TokenModule} from './token/token.module';
MongooseModule.forRoot(environment.mongo.uri),
AuthModule.forRoot(environment.auth),
PollModule,
ParticipantModule,
TokenModule,
StatisticsModule,
MailModule,
Expand Down
52 changes: 52 additions & 0 deletions apps/backend/src/participant/participant.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {CreateParticipantDto, Participant, ReadParticipantDto, UpdateParticipantDto} from '@apollusia/types';
import {AuthUser, UserToken} from '@mean-stream/nestx/auth';
import {ObjectIdPipe} from '@mean-stream/nestx/ref';
import {Body, Controller, Delete, Get, Headers, Param, Post, Put, UseGuards} from '@nestjs/common';
import {Types} from 'mongoose';

import {OptionalAuthGuard} from '../auth/optional-auth.guard';
import {PollActionsService} from '../poll/poll-actions.service';

@Controller('poll/:poll/participate')
export class ParticipantController {
constructor(
private pollService: PollActionsService,
) {
}

@Get()
async findAll(
@Param('poll', ObjectIdPipe) poll: Types.ObjectId,
@Headers('Participant-Token') token: string,
): Promise<ReadParticipantDto[]> {
return this.pollService.getParticipants(poll, token);
}

@Post()
@UseGuards(OptionalAuthGuard)
async create(
@Param('poll', ObjectIdPipe) poll: Types.ObjectId,
@Body() dto: CreateParticipantDto,
@AuthUser() user?: UserToken,
): Promise<Participant> {
return this.pollService.postParticipation(poll, dto, user);
}

@Put(':participant')
async update(
@Param('poll', ObjectIdPipe) poll: Types.ObjectId,
@Param('participant', ObjectIdPipe) participant: Types.ObjectId,
@Headers('Participant-Token') token: string,
@Body() dto: UpdateParticipantDto,
): Promise<ReadParticipantDto | null> {
return this.pollService.editParticipation(poll, participant, token, dto);
}

@Delete(':participant')
async delete(
@Param('poll', ObjectIdPipe) poll: Types.ObjectId,
@Param('participant', ObjectIdPipe) participant: Types.ObjectId,
): Promise<ReadParticipantDto | null> {
return this.pollService.deleteParticipation(poll, participant);
}
}
25 changes: 25 additions & 0 deletions apps/backend/src/participant/participant.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {Participant, ParticipantSchema} from '@apollusia/types';
import {Module} from '@nestjs/common';
import {MongooseModule} from '@nestjs/mongoose';

import {ParticipantController} from './participant.controller';
import {ParticipantService} from './participant.service';
import {MailModule} from '../mail/mail.module';
import {PollModule} from '../poll/poll.module';
import {PushModule} from '../push/push.module';

@Module({
imports: [
MongooseModule.forFeature([
{name: Participant.name, schema: ParticipantSchema},
]),
MailModule,
PushModule,
PollModule,
],
providers: [ParticipantService],
exports: [ParticipantService],
controllers: [ParticipantController],
})
export class ParticipantModule {
}
14 changes: 14 additions & 0 deletions apps/backend/src/participant/participant.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {Participant} from '@apollusia/types';
import {MongooseRepository} from '@mean-stream/nestx/resource';
import {Injectable} from '@nestjs/common';
import {InjectModel} from '@nestjs/mongoose';
import {Model} from 'mongoose';

@Injectable()
export class ParticipantService extends MongooseRepository<Participant> {
constructor(
@InjectModel(Participant.name) model: Model<Participant>,
) {
super(model);
}
}
51 changes: 51 additions & 0 deletions apps/backend/src/poll-event/poll-event.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {PollEvent, PollEventDto, ReadPollEventDto} from '@apollusia/types';
import {AuthUser, UserToken} from '@mean-stream/nestx/auth';
import {notFound} from '@mean-stream/nestx/not-found';
import {ObjectIdPipe} from '@mean-stream/nestx/ref';
import {
Body,
Controller,
ForbiddenException,
Get,
Headers,
Param,
ParseArrayPipe,
Post,
UseGuards,
} from '@nestjs/common';
import {Types} from 'mongoose';

import {OptionalAuthGuard} from '../auth/optional-auth.guard';
import {PollActionsService} from '../poll/poll-actions.service';

@Controller('poll/:poll/events')
export class PollEventController {
constructor(
private pollService: PollActionsService,
) {
}

@Get()
async getEvents(
@Param('poll', ObjectIdPipe) poll: Types.ObjectId,
): Promise<ReadPollEventDto[]> {
await this.pollService.find(poll) ?? notFound(poll);
return this.pollService.getEvents(poll);
}

@Post()
@UseGuards(OptionalAuthGuard)
async postEvents(
@Param('poll', ObjectIdPipe) poll: Types.ObjectId,
@Body(new ParseArrayPipe({items: PollEventDto})) pollEvents: PollEventDto[],
@Headers('Participant-Token') token?: string,
@AuthUser() user?: UserToken,
): Promise<PollEvent[]> {
const pollDoc = await this.pollService.find(poll) ?? notFound(poll);
if (!this.pollService.isAdmin(pollDoc, token, user?.sub)) {
throw new ForbiddenException('You are not allowed to edit events for this poll');
}
return this.pollService.postEvents(poll, pollEvents);
}

}
25 changes: 25 additions & 0 deletions apps/backend/src/poll-event/poll-event.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {PollEvent, PollEventSchema} from '@apollusia/types';
import {Module} from '@nestjs/common';
import {MongooseModule} from '@nestjs/mongoose';

import {PollEventController} from './poll-event.controller';
import {PollEventService} from './poll-event.service';
import {MailModule} from '../mail/mail.module';
import {PollModule} from '../poll/poll.module';
import {PushModule} from '../push/push.module';

@Module({
imports: [
MongooseModule.forFeature([
{name: PollEvent.name, schema: PollEventSchema},
]),
MailModule,
PushModule,
PollModule,
],
providers: [PollEventService],
exports: [PollEventService],
controllers: [PollEventController],
})
export class PollEventModule {
}
14 changes: 14 additions & 0 deletions apps/backend/src/poll-event/poll-event.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {PollEvent} from '@apollusia/types';
import {MongooseRepository} from '@mean-stream/nestx/resource';
import {Injectable} from '@nestjs/common';
import {InjectModel} from '@nestjs/mongoose';
import {Model} from 'mongoose';

@Injectable()
export class PollEventService extends MongooseRepository<PollEvent> {
constructor(
@InjectModel(PollEvent.name) model: Model<PollEvent>,
) {
super(model);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import {MongooseModule} from '@nestjs/mongoose';
import {Test, TestingModule} from '@nestjs/testing';
import {Model, Types} from 'mongoose';

import {PollService} from './poll.service';
import {ParticipantStub, PollEventStub, PollStub} from '../../../test/stubs';
import {PollModule} from '../poll.module';
import {PollActionsService} from './poll-actions.service';
import {PollModule} from './poll.module';
import {ParticipantStub, PollEventStub, PollStub} from '../../test/stubs';

describe('PollService', () => {
let service: PollService;
describe('PollActionsService', () => {
let service: PollActionsService;
let pollModel: Model<Poll>;
let pollEventModel: Model<PollEventDto>;

Expand All @@ -23,7 +23,7 @@ describe('PollService', () => {

pollModel = module.get('PollModel');
pollEventModel = module.get('PollEventModel');
service = module.get<PollService>(PollService);
service = module.get<PollActionsService>(PollActionsService);
});

let pollStubId;
Expand All @@ -42,7 +42,7 @@ describe('PollService', () => {
});

it('should get all polls', async () => {
const polls = await service.getPolls(PollStub().adminToken, true);
const polls = await service.getPolls(PollStub().adminToken, undefined, true);
expect(polls).toBeDefined();
expect(polls.length).toEqual(1);
});
Expand All @@ -60,7 +60,7 @@ describe('PollService', () => {
modifiedPoll.title = 'Meeting';
const modifiedPollId = new Types.ObjectId('9e9e9e9e9e9e9e9e9e9e9e9e');

await expect(service.putPoll(modifiedPollId, modifiedPoll)).rejects.toThrow(NotFoundException);
expect(await service.putPoll(modifiedPollId, modifiedPoll)).toBeNull();
const updatedPoll = await pollModel.findById(pollStubId).exec();
const pollCounts = await pollModel.countDocuments().exec();

Expand Down Expand Up @@ -94,7 +94,7 @@ describe('PollService', () => {
let pollCounts = await pollModel.countDocuments().exec();
expect(pollCounts).toEqual(1);

await expect(service.deletePoll(pollStubId)).rejects.toThrow(NotFoundException);
expect(await service.deletePoll(pollStubId)).toBeNull();
pollCounts = await pollModel.countDocuments().exec();

expect(pollCounts).toEqual(1);
Expand Down Expand Up @@ -145,7 +145,7 @@ describe('PollService', () => {

it('should be admin', async () => {
const poll = await pollModel.findOne({title: 'Party (clone)'}).exec();
const isAdmin = await service.isAdmin(poll._id, ParticipantStub().token);
const isAdmin = service.isAdmin(poll, ParticipantStub().token, undefined);
expect(isAdmin).toEqual(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,20 @@ import {
ShowResultOptions,
UpdateParticipantDto,
} from '@apollusia/types';
import {Doc} from '@mean-stream/nestx';
import {UserToken} from '@mean-stream/nestx/auth';
import {Doc} from '@mean-stream/nestx/ref';
import {Injectable, Logger, NotFoundException, OnModuleInit, UnprocessableEntityException} from '@nestjs/common';
import {InjectModel} from '@nestjs/mongoose';
import {Document, FilterQuery, Model, Types} from 'mongoose';

import {environment} from '../../environment';
import {renderDate} from '../../mail/helpers';
import {MailService} from '../../mail/mail/mail.service';
import {PushService} from '../../push/push.service';
import {environment} from '../environment';
import {renderDate} from '../mail/helpers';
import {MailService} from '../mail/mail/mail.service';
import {PushService} from '../push/push.service';

@Injectable()
export class PollService implements OnModuleInit {
private logger = new Logger(PollService.name);
export class PollActionsService implements OnModuleInit {
private logger = new Logger(PollActionsService.name);

constructor(
@InjectModel(Poll.name) private pollModel: Model<Poll>,
Expand Down Expand Up @@ -152,10 +152,12 @@ export class PollService implements OnModuleInit {
};
}

async getPolls(token: string, active: boolean | undefined): Promise<ReadStatsPollDto[]> {
async getPolls(token: string, user: string | undefined, active: boolean | undefined): Promise<ReadStatsPollDto[]> {
return this.readPolls({
adminToken: token,
...this.activeFilter(active),
$and: [
user ? {$or: [{createdBy: user}, {adminToken: token}]} : {adminToken: token},
this.activeFilter(active),
],
});
}

Expand All @@ -176,7 +178,12 @@ export class PollService implements OnModuleInit {
.exec();
}

async getPoll(id: Types.ObjectId): Promise<Doc<ReadPollDto>> {
// Only for internal use
async find(id: Types.ObjectId): Promise<Doc<Poll>> {
return this.pollModel.findById(id).exec();
}

async getPoll(id: Types.ObjectId): Promise<Doc<ReadPollDto> | null> {
return this.pollModel
.findById(id)
.select(readPollSelect)
Expand All @@ -198,16 +205,15 @@ export class PollService implements OnModuleInit {
return rest;
}

async putPoll(id: Types.ObjectId, pollDto: PollDto): Promise<ReadPollDto> {
const poll = await this.pollModel.findByIdAndUpdate(id, pollDto, {new: true}).select(readPollSelect).exec();
if (!poll) {
throw new NotFoundException(id);
}
return poll;
async putPoll(id: Types.ObjectId, pollDto: PollDto): Promise<ReadPollDto | null> {
return this.pollModel.findByIdAndUpdate(id, pollDto, {new: true}).select(readPollSelect).exec();
}

async clonePoll(id: Types.ObjectId): Promise<ReadPollDto> {
async clonePoll(id: Types.ObjectId): Promise<ReadPollDto | null> {
const poll = await this.pollModel.findById(id).exec();
if (!poll) {
return null;
}
const {_id, id: _, title, ...rest} = poll.toObject();
const pollEvents = await this.pollEventModel.find({poll: new Types.ObjectId(id)}).exec();
const clonedPoll = await this.postPoll({
Expand All @@ -223,14 +229,10 @@ export class PollService implements OnModuleInit {
return clonedPoll;
}

async deletePoll(id: Types.ObjectId): Promise<ReadPollDto> {
async deletePoll(id: Types.ObjectId): Promise<ReadPollDto | null> {
const poll = await this.pollModel.findByIdAndDelete(id, {projection: readPollSelect}).exec();
if (!poll) {
throw new NotFoundException(id);
}

await this.pollEventModel.deleteMany({poll: new Types.ObjectId(id)}).exec();
await this.participantModel.deleteMany({poll: new Types.ObjectId(id)}).exec();
await this.pollEventModel.deleteMany({poll: id}).exec();
await this.participantModel.deleteMany({poll: id}).exec();
return poll;
}

Expand Down Expand Up @@ -463,8 +465,8 @@ export class PollService implements OnModuleInit {
}).exec();
}

async isAdmin(id: Types.ObjectId, token: string) {
return this.pollModel.findById(id).exec().then(poll => poll.adminToken === token);
isAdmin(poll: Poll, token: string | undefined, user: string | undefined) {
return poll.adminToken === token || poll.createdBy === user;
}

async claimPolls(adminToken: string, createdBy: string): Promise<void> {
Expand Down
Loading

0 comments on commit ab51129

Please sign in to comment.