Skip to content

Commit

Permalink
Merge pull request #165 from Morphclue/feat/book-participants
Browse files Browse the repository at this point in the history
Book Individual Participants
  • Loading branch information
Morphclue authored May 18, 2024
2 parents 7b54ddf + c68724f commit 7155a65
Show file tree
Hide file tree
Showing 11 changed files with 152 additions and 54 deletions.
26 changes: 17 additions & 9 deletions apps/backend/src/poll/poll/poll.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,21 @@ export class PollController {
return this.pollService.deleteParticipation(id, participantId);
}

@Post(':id/book')
async bookEvents(@Param('id', ObjectIdPipe) id: Types.ObjectId, @Body() events: string[]): Promise<ReadPollDto> {
const poll = await this.pollService.getPoll(id);
if (!poll) {
throw new NotFoundException(id);
}

return this.pollService.bookEvents(id, events.map(e => new Types.ObjectId(e)));
}
@Post(':id/book')
async bookEvents(
@Param('id', ObjectIdPipe) id: Types.ObjectId,
@Body() events: Record<string, string[] | true>,
): Promise<ReadPollDto> {
const poll = await this.pollService.getPoll(id);
if (!poll) {
throw new NotFoundException(id);
}

// convert nested strings to ObjectIds
const bookedEvents = Object.fromEntries(Object
.entries(events)
.map(([key, value]) => [key, value === true ? true as const : value.map(v => new Types.ObjectId(v))]),
);
return this.pollService.bookEvents(id, bookedEvents);
}
}
73 changes: 60 additions & 13 deletions apps/backend/src/poll/poll/poll.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export class PollService implements OnModuleInit {
this.migrateSelection(),
this.migratePollEvents(),
this.migrateShowResults(),
this.migrateBookedEvents(),
]);
}

Expand Down Expand Up @@ -114,6 +115,26 @@ export class PollService implements OnModuleInit {
result.modifiedCount && this.logger.log(`Migrated ${result.modifiedCount} polls to the new show result format.`);
}

private async migrateBookedEvents() {
// migrate all Poll's bookedEvents: ObjectId[] to Record<ObjectId, true>
const polls = await this.pollModel.find({bookedEvents: {$type: 'array'}}).exec();
if (!polls.length) {
return;
}

for (const poll of polls) {
const bookedEvents = poll.bookedEvents as unknown as Types.ObjectId[];
const newBookedEvents: Record<string, true> = {};
for (const event of bookedEvents) {
newBookedEvents[event.toString()] = true;
}
poll.bookedEvents = newBookedEvents;
poll.markModified('bookedEvents');
}
await this.pollModel.bulkSave(polls, {timestamps: false});
this.logger.log(`Migrated ${polls.length} polls to the new booked events format.`);
}

private activeFilter(active: boolean | undefined): FilterQuery<Poll> {
if (active === undefined) {
return {};
Expand Down Expand Up @@ -360,29 +381,55 @@ export class PollService implements OnModuleInit {
return this.participantModel.findByIdAndDelete(participantId, {projection: readParticipantSelect}).exec();
}

async bookEvents(id: Types.ObjectId, events: Types.ObjectId[]): Promise<ReadPollDto> {
async bookEvents(id: Types.ObjectId, events: Poll['bookedEvents']): Promise<ReadPollDto> {
const poll = await this.pollModel.findByIdAndUpdate(id, {
bookedEvents: events,
}, {new: true})
.populate<{bookedEvents: PollEvent[]}>('bookedEvents')
.select(readPollSelect)
.exec();
for await (const participant of this.participantModel.find({poll: new Types.ObjectId(id)})) {
const appointments = poll.bookedEvents.map(event => {
let eventLine = this.renderEvent(event, undefined, poll.timeZone);
const selection = participant.selection[event._id.toString()];
if (selection === 'yes' || selection === 'maybe') {
eventLine += ' *';
}
return eventLine;
});
const eventDocs = await this.pollEventModel.find({
poll: id,
_id: {$in: Object.keys(events).map(e => new Types.ObjectId(e))},
});
for await (const participant of this.participantModel.find({
poll: new Types.ObjectId(id),
mail: {$exists: true},
})) {
const appointments = eventDocs
.filter(event => {
const booked = events[event._id.toString()];
// only show the events to the participant that are either
if (booked === true) {
// 1) booked entirely, or
return true;
} else if (Array.isArray(booked)) {
// 2) booked for the participant
return booked.some(id => participant._id.equals(id));
} else {
return false;
}
})
.map(event => {
let eventLine = this.renderEvent(event, undefined, poll.timeZone);
const selection = participant.selection[event._id.toString()];
if (selection === 'yes' || selection === 'maybe') {
eventLine += ' *';
}
return eventLine;
});

if (!appointments.length) {
// don't send them an email if there are no appointments
continue;
}

this.mailService.sendMail(participant.name, participant.mail, 'Poll booked', 'book', {
appointments,
poll: poll.toObject(),
participant: participant.toObject(),
}).then();
}).catch(console.error);
}
return {...poll.toObject(), bookedEvents: events};
return poll;
}

private renderEvent(event: PollEvent, locale?: string, timeZone?: string) {
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/test/stubs/PollStub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ export const PollStub = (): PollDto => {
showResult: ShowResultOptions.IMMEDIATELY,
},
adminToken: '619b3a00-2dc3-48f1-8b3d-50386a91a559',
bookedEvents: [],
bookedEvents: {},
};
};
4 changes: 2 additions & 2 deletions apps/frontend/src/app/pipes/some.pipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {Pipe, PipeTransform} from '@angular/core';
name: 'some',
})
export class SomePipe implements PipeTransform {
transform<T>(array: T[], search: T): boolean {
return array.includes(search);
transform<T>(array: T[] | unknown, search: T): boolean {
return Array.isArray(array) && array.includes(search);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export class CreateEditPollComponent implements OnInit {
adminMail: pollForm.emailUpdates ? this.poll?.adminMail || this.mail : undefined,
adminPush: pollForm.pushUpdates && (this.poll?.adminPush || pushToken?.toJSON()) || undefined,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
bookedEvents: [],
bookedEvents: {},
settings: {
deadline: deadline?.toISOString(),
allowMaybe: !!pollForm.allowMaybe,
Expand Down
15 changes: 12 additions & 3 deletions apps/frontend/src/app/poll/ical/ical.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class IcalComponent implements OnInit {
])),
).subscribe(([poll, events, participants]) => {
this.url = new URL(`/poll/${poll.id}/participate`, window.location.origin).href;
this.config.onlyBookedEvents = poll.bookedEvents.length > 0;
this.config.onlyBookedEvents = Object.keys(poll.bookedEvents).length > 0;

const exampleEvent = events.find(e => e.participants > 0) ?? events[0];
this.exampleEvent = {
Expand All @@ -67,7 +67,16 @@ export class IcalComponent implements OnInit {
});

for (const event of this.getExportedEvents()) {
const eventParticipants = participants.filter(p => p.selection[event._id] === 'yes' || p.selection[event._id] === 'maybe');
const eventParticipants = participants.filter(p => {
if (p.selection[event._id] !== 'yes' && p.selection[event._id] !== 'maybe') {
return false;
}
const bookedEvent = poll.bookedEvents[event._id];
if (config.onlyBookedEvents && Array.isArray(bookedEvent) && !bookedEvent.includes(p._id)) {
return false;
}
return true;
});

let summary = config.customTitle || poll.title;
if (eventParticipants.length === 1) {
Expand Down Expand Up @@ -103,7 +112,7 @@ export class IcalComponent implements OnInit {
if (!this.config.emptyEvents && !e.participants) {
return false;
}
if (this.config.onlyBookedEvents && !this.poll?.bookedEvents.includes(e._id)) {
if (this.config.onlyBookedEvents && !this.poll?.bookedEvents[e._id]) {
return false;
}
return true;
Expand Down
4 changes: 2 additions & 2 deletions apps/frontend/src/app/poll/services/poll.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type {PollEventState} from '@apollusia/types';
import {Observable} from 'rxjs';

import {environment} from '../../../environments/environment';
import {CreateParticipantDto, Participant, ReadPoll, ReadPollEvent, UpdateParticipantDto} from '../../model';
import {CreateParticipantDto, Participant, Poll, ReadPoll, ReadPollEvent, UpdateParticipantDto} from '../../model';

@Injectable({
providedIn: 'root',
Expand Down Expand Up @@ -66,7 +66,7 @@ export class PollService {
return this.http.get<boolean>(`${environment.backendURL}/poll/${id}/admin/${adminToken}`);
}

book(id: string, events: string[]) {
book(id: string, events: Poll['bookedEvents']) {
return this.http.post(`${environment.backendURL}/poll/${id}/book`, events);
}

Expand Down
47 changes: 31 additions & 16 deletions apps/frontend/src/app/poll/table/table.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,34 @@
@if (participant !== editParticipant) {
@for (pollEvent of pollEvents; track pollEvent; let n = $index) {
<td>
@switch (participant.selection[pollEvent._id]) {
@case ('yes') {
<h5 class="p-yes bi-check-lg" ngbTooltip="Yes"></h5>
<div class="d-flex">
@switch (participant.selection[pollEvent._id]) {
@case ('yes') {
<h5 class="p-yes bi-check-lg" ngbTooltip="Yes"></h5>
}
@case ('no') {
<h5 class="p-no bi-x-lg" ngbTooltip="No"></h5>
}
@case ('maybe') {
<h5 class="p-maybe bi-question" ngbTooltip="Maybe"></h5>
}
@default {
<h5 class="p-unset bi-question" ngbTooltip="Unspecified"></h5>
}
}
@case ('no') {
<h5 class="p-no bi-x-lg" ngbTooltip="No"></h5>
@if (isAdmin) {
@for (isBooked of [bookedEvents[pollEvent._id] | some:participant._id]; track isBooked // cursed_variable) {
<input
type="checkbox"
class="ms-2 text-secondary form-check-hidden"
[class.bi-star-fill]="isBooked"
[class.bi-star]="!isBooked"
[ngModel]="isBooked"
(ngModelChange)="setBookedParticipant(pollEvent._id, participant._id, $event)"
>
}
}
@case ('maybe') {
<h5 class="p-maybe bi-question" ngbTooltip="Maybe"></h5>
}
@default {
<h5 class="p-unset bi-question" ngbTooltip="Unspecified"></h5>
}
}
</div>
</td>
}
<td class="border-0 text-start">
Expand Down Expand Up @@ -135,14 +149,15 @@ <h5 [class]="bestOption === pollEvent.participants ? 'bi-award text-primary' : '
@if (poll && isAdmin) {
<tr>
<th>Select Events</th>
@for (pollEvent of pollEvents; track pollEvent; let i = $index) {
@for (pollEvent of pollEvents; track pollEvent) {
<td>
<input
type="checkbox"
class="text-primary form-check-hidden"
[class.bi-star-fill]="bookedEvents[i]"
[class.bi-star]="!bookedEvents[i]"
[(ngModel)]="bookedEvents[i]"
[class.bi-star-fill]="bookedEvents[pollEvent._id] === true"
[class.bi-star]="bookedEvents[pollEvent._id] !== true"
[ngModel]="bookedEvents[pollEvent._id] === true"
(ngModelChange)="setBooked(pollEvent._id, $event)"
>
</td>
}
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/app/poll/table/table.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@

h5 {
margin: 0;
flex-grow: 1;
}
26 changes: 21 additions & 5 deletions apps/frontend/src/app/poll/table/table.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {checkParticipant} from '@apollusia/logic';
import type {PollEventState} from '@apollusia/types';
import {ToastService} from '@mean-stream/ngbx';

import {CreateParticipantDto, Participant, ReadPoll, ReadPollEvent, UpdateParticipantDto} from '../../model';
import {CreateParticipantDto, Participant, Poll, ReadPoll, ReadPollEvent, UpdateParticipantDto} from '../../model';
import {PollService} from '../services/poll.service';

@Component({
Expand All @@ -22,7 +22,7 @@ export class TableComponent implements OnInit {

@Output() changed = new EventEmitter<void>();

bookedEvents: boolean[] = [];
bookedEvents: Poll['bookedEvents'] = {};

newParticipant: CreateParticipantDto = {
name: '',
Expand All @@ -40,7 +40,7 @@ export class TableComponent implements OnInit {
}

ngOnInit() {
this.bookedEvents = this.pollEvents.map(e => this.poll.bookedEvents.includes(e._id));
this.bookedEvents = this.poll.bookedEvents || {};
this.newParticipant.token = this.token;
this.clearSelection();
this.validateNew();
Expand Down Expand Up @@ -106,14 +106,30 @@ export class TableComponent implements OnInit {
this.pollService.selectAll(this.poll, this.pollEvents, this.newParticipant, state);
}

setBooked(eventId: string, state: boolean) {
if (state) {
this.bookedEvents[eventId] = true;
} else {
delete this.bookedEvents[eventId];
}
}

book() {
const events = this.pollEvents.filter((e, i) => this.bookedEvents[i]).map(e => e._id);
this.pollService.book(this.poll._id, events).subscribe(() => {
this.pollService.book(this.poll._id, this.bookedEvents).subscribe(() => {
this.toastService.success('Booking', 'Booked events successfully');
});
}

private onChange() {
this.changed.next();
}

setBookedParticipant(eventId: string, participantId: string, state: boolean) {
const original = Array.isArray(this.bookedEvents[eventId]) ? this.bookedEvents[eventId] as string[] : [];
if (state) {
this.bookedEvents[eventId] = [...original, participantId];
} else {
this.bookedEvents[eventId] = original.filter(id => id !== participantId);
}
}
}
6 changes: 4 additions & 2 deletions libs/types/src/lib/schema/poll.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,10 @@ export class Poll {
@ValidateNested()
settings: Settings;

@RefArray('PollEvent')
bookedEvents: Types.ObjectId[];
@Prop({type: Object})
@ApiProperty()
@IsObject()
bookedEvents: Record<string, Types.ObjectId[] | true>;
}

export const PollSchema = SchemaFactory.createForClass(Poll);

0 comments on commit 7155a65

Please sign in to comment.