Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Book Individual Participants #165

Merged
merged 9 commits into from
May 18, 2024
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);